Skip to content
93 changes: 93 additions & 0 deletions common/src/idempotency_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
use std::str::FromStr;

use macros::StrNewtype;
use thiserror::Error;

/// A retry key that identifies one post-creation attempt for a user.
///
/// [`FromStr`] is the single validation and canonicalization chokepoint: outer
/// whitespace is removed, and an empty result is rejected. Any other text is
/// preserved, because key format is chosen by the client rather than this domain
/// type. The ADR-0063 string-newtype trailer (`Display`, `AsRef`/`Borrow`/
/// `Deref<str>`, owned `String` conversions, `PartialEq<str>`/`<&str>`, ordering,
/// and validating serde and sqlx bridges) is generated by `#[derive(StrNewtype)]`.
#[derive(Clone, Debug, PartialEq, Eq, Hash, StrNewtype)]
pub struct IdempotencyKey(String);

/// Error returned when a string cannot be parsed as an [`IdempotencyKey`].
#[derive(Debug, Error)]
#[error("idempotency key must be non-empty")]
pub struct InvalidIdempotencyKey;

impl FromStr for IdempotencyKey {
type Err = InvalidIdempotencyKey;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(InvalidIdempotencyKey);
}
Ok(Self(trimmed.to_owned()))
}
}

#[cfg(test)]
mod tests {
use std::borrow::Borrow;

use super::*;

#[test]
fn parses_trimmed_arbitrary_text_without_format_restrictions() {
let key: IdempotencyKey = " \t retry:🦀/v1 \n".parse().unwrap();

assert_eq!(key, "retry:🦀/v1");
let canonical: &str = key.as_ref();
assert_eq!(canonical, "retry:🦀/v1");
}

#[test]
fn rejects_empty_and_whitespace_only_keys() {
for input in ["", " ", "\t\n", "\u{2003}"] {
assert!(input.parse::<IdempotencyKey>().is_err(), "{input:?}");
}
assert_eq!(
InvalidIdempotencyKey.to_string(),
"idempotency key must be non-empty"
);
}

#[test]
fn serde_round_trips_canonical_key_and_rejects_blank_input() {
let key: IdempotencyKey = " retry key ".parse().unwrap();

assert_eq!(serde_json::to_string(&key).unwrap(), "\"retry key\"");
assert_eq!(
serde_json::from_str::<IdempotencyKey>("\" retry key \"").unwrap(),
key
);
assert!(serde_json::from_str::<IdempotencyKey>("\"\"").is_err());
assert!(serde_json::from_str::<IdempotencyKey>("\" \\t \"").is_err());
}

#[test]
fn standard_trailer_supports_owned_and_borrowed_access() {
let key = IdempotencyKey::try_from(" retry-key ".to_owned()).unwrap();

assert_eq!(key.as_ref(), "retry-key");
let via_borrow: &str = key.borrow();
assert_eq!(via_borrow, "retry-key");
let borrowed: &str = &key;
assert_eq!(borrowed, "retry-key");
assert_eq!(String::from(key), "retry-key");
}

#[test]
fn standard_trailer_compares_and_orders_by_canonical_string() {
let earlier: IdempotencyKey = " alpha ".parse().unwrap();
let later: IdempotencyKey = "beta".parse().unwrap();

assert_eq!(earlier, "alpha");
assert!(earlier < later);
}
}
1 change: 1 addition & 0 deletions common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod display_name;
pub mod email;
pub mod etag;
pub mod feed;
pub mod idempotency_key;
pub mod ids;
pub mod invite;
pub mod list_state;
Expand Down
27 changes: 16 additions & 11 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,24 @@ not canonicalized across backends

### Idempotent post creation

Post creation accepts an optional client-supplied idempotency key, so a retried
Post creation accepts an optional client-supplied
[`IdempotencyKey`](adr/0063-domain-value-newtype-convention.md), so a retried
AtomPub POST does not create a duplicate post — the mechanism decided in issue
[#79](https://github.com/jaunder-org/jaunder/issues/79) as a follow-on to
ADR-0047, not in an ADR of its own. The `idempotency_keys` table (migration
`0023_create_idempotency_keys`, `UNIQUE(user_id, key)`) is written in the same
transaction as the post (`storage/src/posts.rs:2274`); a duplicate key surfaces
as `CreatePostError::IdempotencyConflict` and is deliberately _not_ retried as a
slug collision (`storage/src/post_service.rs:466`), and
`PostStorage::post_id_for_idempotency_key` maps a replayed key back to the post
it originally created. AtomPub is its only caller
(`server/src/atompub/posts.rs:366`); the web composer passes
`idempotency_key: None` (`web/src/posts/api.rs:188`), so the mechanism is a
machine-client contract, not a browser one.
ADR-0047. At the AtomPub boundary, a missing header, a value rejected by
`HeaderValue::to_str` (including non-ASCII UTF-8 bytes and invalid UTF-8), or
text that is blank after trimming means no key rather than a `400`. A readable,
non-blank value is parsed once into an owned `IdempotencyKey`; typed borrowed
keys carry it through post creation and duplicate lookup, and the owned type is
bound for persistence.

The existing `idempotency_keys` table needs no schema migration: it stores the
key as `TEXT NOT NULL` and enforces `UNIQUE(user_id, key)`. A fresh keyed create
writes its post and key row atomically; a uniqueness collision rolls the
attempted creation back. The fresh keyed create returns `201`; when its original
post remains available, same-user key reuse returns that original post as `200`,
even when the new payload differs. Another user may use the same key
independently, and key rows are retained indefinitely.

### Testing (summary)

Expand Down
16 changes: 16 additions & 0 deletions docs/adr/0063-domain-value-newtype-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,22 @@ values with no serialization hop and so take the newtype like any other surface
we define (`CapturedPing`, `server/tests/helpers/websub_capturing.rs`). The
distinction is whether the double is observing bytes or receiving values.

**2026-08-27 adoption — `IdempotencyKey`.** Issue #1086 adopts `IdempotencyKey`
as a non-secret string-backed domain value. It prevents a retry key from being
transposed with another string while it crosses AtomPub, post creation,
duplicate lookup, and persistence. Its `FromStr` trims outer whitespace, rejects
an empty result, and preserves every other string; the standard trailer supplies
the validating serde and SQLx bridges.

AtomPub retains one compatibility seam before that outermost parse:
`HeaderValue::to_str` may reject a header value (including non-ASCII UTF-8 bytes
and invalid UTF-8), and missing, rejected, or blank-after-trimming values mean
no key rather than a `400`. Only readable, non-blank header text becomes an
owned `IdempotencyKey`; every Jaunder-defined surface thereafter remains typed,
with borrowed keys for orchestration and lookup and owned keys for creation
input and persistence. This type-only adoption needs no schema migration. The
frozen #697 historical artifacts remain unchanged.

## Consequences

- **One decision surface.** "Does this value deserve a type, and what shape does
Expand Down
69 changes: 69 additions & 0 deletions docs/archive/2026-08-26-issue-1086-idempotency-key-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Idempotency key domain type implementation outline

> Execute with dev-cycle-iterate. This outline exists because a new shared
> domain type changes storage/service and AtomPub contracts in dependency order.

## Scope

In:

- Shared `IdempotencyKey` type and standard trailer.
- Typed post-service/storage contracts and existing-schema SQLx use.
- Compatible AtomPub header parsing and replay behavior.
- Focused type, dual-backend storage, and AtomPub boundary coverage.
- ADR-0063 and current architecture projection updates.

Out:

- Schema changes, retention, payload fingerprints, new response statuses, client
format changes, and edits to frozen #697 artifacts.

## Task outline

- [x] Task 1: Establish the shared idempotency-key contract
- Contract: `common::idempotency_key::IdempotencyKey` is a non-secret
SQLx-enabled string newtype; `FromStr` trims, rejects empty, and otherwise
preserves the trimmed string. Its named error and standard trailer follow
existing ADR-0063 types.
- Verification: focused `common` tests prove canonicalization,
arbitrary-string acceptance, empty rejection, serde/owned-borrowed behavior,
and the repository's standard trailer requirements.

- [x] Task 2: Carry typed keys from AtomPub through persistence
- Depends on: Task 1.
- Contract: the AtomPub handler preserves `HeaderValue::to_str` compatibility,
maps missing/whitespace-only/unreadable values to `None`, and parses
readable non-empty text once into owned `IdempotencyKey`. Borrowed
service/orchestration and lookup seams use `Option<&IdempotencyKey>` /
`&IdempotencyKey`; lifetime-free content/input structs own
`Option<IdempotencyKey>`; SQL lookup and insert bind the type directly.
Every caller migrates in this task, with no primitive compatibility overload
or migration.
- Verification: dual-backend storage/service tests prove typed SQLx behavior
and atomic rollback on collision. Dual-backend router tests prove valid
first/reused keys, different content on reuse, per-user scope,
whitespace-only, non-ASCII UTF-8 bytes, invalid UTF-8 bytes, and no-key
behavior through real HTTP requests.

- [x] Task 3: Record the completed domain contract
- Depends on: Tasks 1–2.
- Contract: ADR-0063 and `docs/ARCHITECTURE.md` describe the type,
compatibility boundary, per-user persistence, and replay semantics; frozen
#697 artifacts remain unchanged.
- Verification: documentation/gate checks resolve all live paths and
references without modifying archived history.

## Risk checks

- Every idempotency-specific exported symbol migration includes all callsites
before its task is committed; no raw-string shim or deprecated path remains.
- AtomPub unreadable-header cases are constructed as raw `HeaderValue` bytes so
tests exercise `to_str` rejection rather than only the domain parser.
- SQLx decode validates stored rows; both existing database dialects remain
schema-identical.
- Collision tests distinguish the original committed post from every row
belonging to the rolled-back attempt.
- Slug collision retries remain separate from idempotency conflicts and retain
their current retry behavior.
- Integrated verification runs the repository's changed-contract checks and
normal commit gate after all tasks land.
88 changes: 88 additions & 0 deletions docs/archive/2026-08-26-issue-1086-idempotency-key-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Issue #1086 — Model post idempotency keys as a domain value

## Outcome

AtomPub create requests parse a retry token once into `IdempotencyKey`, and that
domain type remains intact through post creation, duplicate lookup, and database
insertion. Existing client compatibility and replay behavior remain unchanged.

The type removes primitive transposition and stripping risk without changing the
HTTP grammar or database schema.

## Load-bearing decisions

- `IdempotencyKey` is a non-secret string domain type in `common`, following
ADR-0063's standard string-newtype trailer.
- `FromStr` is the validation and canonicalization chokepoint: trim outer
whitespace, reject an empty result, and store the trimmed value.
- `IdempotencyKey` itself accepts any otherwise-arbitrary non-empty string.
There is no character-set, byte-length, scalar-length, UUID, base64url, or RFC
8941 structured-field restriction.
- AtomPub preserves the existing `HeaderValue::to_str` compatibility boundary:
- missing header means no idempotency key;
- whitespace-only readable header text means no idempotency key;
- bytes rejected by `HeaderValue::to_str` — including non-ASCII UTF-8 and
invalid UTF-8 — mean no idempotency key;
- non-empty readable header text becomes an owned `IdempotencyKey`.
- Boundary compatibility is deliberate: empty and unreadable headers are treated
as absent rather than rejected with HTTP 400.
- Borrowed orchestration and lookup APIs use `Option<&IdempotencyKey>`;
lifetime-free creation inputs and persisted values use owned
`Option<IdempotencyKey>`.
- The ordinary non-secret SQLx bridge remains enabled. SQL binds and decodes
operate on `IdempotencyKey`; decode revalidates the domain invariant.
- No migration is needed. Both backends already store the key as `TEXT NOT NULL`
with `UNIQUE(user_id, key)` and no length/check constraint.
- Uniqueness remains scoped by `UserId`, not global.
- Existing replay semantics remain authoritative:
- a fresh keyed create returns 201;
- reusing the key for the same user returns the original post as 200;
- payload equality is not checked, so reuse with different content still
returns the original post;
- another user may independently use the same key;
- the key row is inserted atomically with the post, and a uniqueness collision
rolls the attempted post back;
- keys remain retained indefinitely.
- The issue's request to remove #697's follow-up note is superseded by the
repository's frozen-archive rule: the historical plan remains unchanged.
Closing #1086 resolves the follow-up, while ADR-0063 records `IdempotencyKey`
as an adopted domain value.

## Acceptance

- `common` exposes `IdempotencyKey` with the standard owned/borrowed/serde/SQLx
string-newtype interface and a named parse error.
- Type-level tests prove trimming, non-empty enforcement, acceptance of
otherwise arbitrary UTF-8 text, serde behavior, SQLx compatibility where
convention requires it, and the standard trailer contract.
- AtomPub parses a present header into an owned `IdempotencyKey` before calling
post creation.
- No raw `String` or `str` represents an idempotency key in post-service inputs,
post-storage inputs, duplicate lookup, or database binds.
- Valid keyed create behavior remains 201 on first use and 200 with the original
post on reuse.
- Whitespace-only, non-ASCII UTF-8, and invalid-UTF-8 header cases exercise the
real AtomPub request boundary and behave exactly like an absent key.
- Reusing a key with different post content returns the original post without
creating the attempted post.
- The same key can create one post for each of two different users.
- Existing transactional behavior remains intact: a collision leaves no
attempted post, audience, media-reference, or idempotency row behind.
- Both SQLite and PostgreSQL execute the storage/AtomPub contract tests
according to repository backend conventions.
- ADR-0063 and the current architecture projection describe the typed
idempotency-key contract; frozen #697 artifacts remain historical.

## Boundaries

- No database migration, new index, retention policy, garbage collection,
expiry, or background cleanup.
- No payload fingerprint, request-body comparison, conflict response, or 409
behavior.
- No new client-generated key format and no change to the Emacs/client retry
algorithm.
- No RFC 8941 structured-field parsing and no dependence on the expired IETF
idempotency-key draft.
- No broad HTTP-header validation cleanup outside `Idempotency-Key`.
- No behavior change to unkeyed post creation, slug retries, tags, audiences,
media references, or publication state.
21 changes: 13 additions & 8 deletions server/src/atompub/posts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use serde::Deserialize;

use common::atompub::{CollectionFeedTitle, Entry, FeedMeta, entry_to_xml, render_feed};
use common::etag::{ETag, post_content_etag};
use common::idempotency_key::IdempotencyKey;
use common::ids::PostId;
use common::org::{OrgOperation, OrgStructuredMetadata, Presence, PublicationState, normalize_org};
use common::pagination::PageSize;
Expand Down Expand Up @@ -120,6 +121,15 @@ fn if_match_satisfied(headers: &HeaderMap, etag: &ETag) -> bool {
None => true,
}
}

/// Parses the optional retry key while preserving `AtomPub`'s compatibility policy:
/// unreadable or blank headers do not opt the request into deduplication.
fn idempotency_key_from_headers(headers: &HeaderMap) -> Option<IdempotencyKey> {
headers
.get("idempotency-key")
.and_then(|value| value.to_str().ok()?.parse().ok())
}

fn scalar_presence<T: Clone>(value: Option<&T>) -> Presence<T> {
value.cloned().map_or(Presence::Absent, Presence::Present)
}
Expand Down Expand Up @@ -478,12 +488,7 @@ pub async fn collection_post(
Presence::Present(audiences) => audiences,
Presence::Absent => vec![site_config.get_default_audience().await?.into()],
};
// A client-supplied idempotency key dedups a retried create (duplicate-on-retry).
let idem = headers
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty());
let idempotency_key = idempotency_key_from_headers(&headers);

let created = storage::perform_post_creation(
posts,
Expand All @@ -497,7 +502,7 @@ pub async fn collection_post(
max_attempts: 100,
summary,
audiences,
idempotency_key: idem,
idempotency_key: idempotency_key.as_ref(),
expectations,
},
)
Expand All @@ -511,7 +516,7 @@ pub async fn collection_post(
// A reused idempotency key returns the original post as `200` — skipping category
// re-application (the original already carries its tags).
if let Err(storage::PerformCreationError::IdempotencyConflict) = &created {
let key = idem.ok_or(HandlerError::Invariant)?;
let key = idempotency_key.as_ref().ok_or(HandlerError::Invariant)?;
let post_id = posts
.post_id_for_idempotency_key(auth_user.user_id, key)
.await?
Expand Down
Loading