diff --git a/.claude/guides/07-docs-package.md b/.claude/guides/07-docs-package.md
index 9665ef593..f9972df5f 100644
--- a/.claude/guides/07-docs-package.md
+++ b/.claude/guides/07-docs-package.md
@@ -50,7 +50,11 @@ up-to-date `main` checkout at the repository root:
npm run deploy
```
-Then create the matching Docs GitHub Release as documented in
-`.claude/commands/release.md`. Branch guards, version ownership, and the full
-deployment contract live in `knowledge/internal/06-git-deployment.md`; do not
-duplicate them here.
+Merging to `main` does not publish docs — there is no docs deploy workflow, so
+this local command is the only path to production.
+
+A routine docs deployment stops there. Do not create a Docs GitHub Release for
+it; that step belongs to a spec release, as documented in
+`.claude/commands/release.md`. Branch guards, version ownership, deploy
+verification, and the full deployment contract live in
+`knowledge/internal/06-git-deployment.md`; do not duplicate them here.
diff --git a/.claude/guides/08-deployment.md b/.claude/guides/08-deployment.md
index f4ec7e1a3..cc2b8da42 100644
--- a/.claude/guides/08-deployment.md
+++ b/.claude/guides/08-deployment.md
@@ -13,11 +13,12 @@ This file is a route map, not a second deployment specification.
## Deployment surfaces
-| Surface | Canonical entrypoint |
-| -------------------------------------- | ------------------------------------------------------------------- |
-| Apple, Google, and framework libraries | Sequential stable workflows listed in `.claude/commands/release.md` |
-| Production docs and spec release | Root `npm run deploy`, then `release.yml` with `version=current` |
-| IAPKit | `.github/workflows/deploy-kit.yml` on relevant pushes to `main` |
+| Surface | Canonical entrypoint |
+| -------------------------------------- | ---------------------------------------------------------------------------- |
+| Apple, Google, and framework libraries | Sequential stable workflows listed in `.claude/commands/release.md` |
+| Production docs (routine) | Root `npm run deploy` only — no GitHub Release, and never automatic on merge |
+| Spec release | Root `npm run deploy`, then `release.yml` with `version=current` |
+| IAPKit | `.github/workflows/deploy-kit.yml` on relevant pushes to `main` |
For the rare IAPKit manual fallback, follow the Convex-first sequence in
`packages/kit/README.md#deployment-convex--flyio`. IAPKit has its own Convex
diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md
index 7230a21ec..e81fee9c5 100644
--- a/knowledge/_claude-context/context.md
+++ b/knowledge/_claude-context/context.md
@@ -1,7 +1,7 @@
# OpenIAP Project Context
> **Auto-generated for Claude Code**
-> Last updated: 2026-08-01T08:01:07.836Z
+> Last updated: 2026-08-02T11:31:06.959Z
>
> Usage: `claude --context knowledge/_claude-context/context.md`
@@ -1958,6 +1958,16 @@ node --test scripts/release-branch-policy.test.mjs
### Deploying Documentation
+**Merging to `main` does not publish documentation.** No workflow deploys the
+production docs on merge; `deploy-kit.yml` auto-deploys IAPKit instead.
+Production docs go out only when a human runs the local deploy below.
+
+This matters most for a PR that changes both `packages/kit/` and
+`packages/docs/`: the kit server auto-deploys from `main` while the docs half
+stays on the previously deployed build. Server behavior can therefore go live
+while the documentation describing it is still unpublished. After merging such a
+PR, deploy the docs and verify both surfaces.
+
Production documentation is stable-only and must deploy from a clean `main`
checkout that exactly matches `origin/main`. The script rejects prerelease spec
versions, other branches, and stale or unpublished local snapshots.
@@ -1973,16 +1983,24 @@ This will:
2. Typecheck and build the docs site
3. Deploy production documentation to Vercel
-It does **not** trigger the Docs GitHub Release. After the Vercel deployment is
-verified, run the stable Docs workflow without another version bump:
+`npm run deploy` uses the current native-derived `spec` value from
+`openiap-versions.json`. It rejects any explicit argument that differs from the
+native floor; docs deployment is not a version-bump path.
+
+**Routine docs deployments stop here.** Do not follow them with a Docs GitHub
+Release: the spec version has not moved, so the release would carry no new
+version information and only adds tag churn. Run the stable Docs workflow only
+when the spec version itself is being released, or when the maintainer asks for
+it explicitly:
```bash
gh workflow run release.yml --ref main -f version=current
```
-`npm run deploy` uses the current native-derived `spec` value from
-`openiap-versions.json`. It rejects any explicit argument that differs from the
-native floor; docs deployment is not a version-bump path.
+Verifying a docs deployment: `llms-full.txt` carries a `Generated:` timestamp
+that must match the committed file, and the deployed entry bundle should contain
+any newly added page copy. A stale timestamp under a cache-busting query string
+means the deploy has not landed, not that a CDN is caching.
---
diff --git a/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md
index d1e63e80e..43abe8df2 100644
--- a/knowledge/internal/06-git-deployment.md
+++ b/knowledge/internal/06-git-deployment.md
@@ -216,6 +216,16 @@ node --test scripts/release-branch-policy.test.mjs
### Deploying Documentation
+**Merging to `main` does not publish documentation.** No workflow deploys the
+production docs on merge; `deploy-kit.yml` auto-deploys IAPKit instead.
+Production docs go out only when a human runs the local deploy below.
+
+This matters most for a PR that changes both `packages/kit/` and
+`packages/docs/`: the kit server auto-deploys from `main` while the docs half
+stays on the previously deployed build. Server behavior can therefore go live
+while the documentation describing it is still unpublished. After merging such a
+PR, deploy the docs and verify both surfaces.
+
Production documentation is stable-only and must deploy from a clean `main`
checkout that exactly matches `origin/main`. The script rejects prerelease spec
versions, other branches, and stale or unpublished local snapshots.
@@ -231,16 +241,24 @@ This will:
2. Typecheck and build the docs site
3. Deploy production documentation to Vercel
-It does **not** trigger the Docs GitHub Release. After the Vercel deployment is
-verified, run the stable Docs workflow without another version bump:
+`npm run deploy` uses the current native-derived `spec` value from
+`openiap-versions.json`. It rejects any explicit argument that differs from the
+native floor; docs deployment is not a version-bump path.
+
+**Routine docs deployments stop here.** Do not follow them with a Docs GitHub
+Release: the spec version has not moved, so the release would carry no new
+version information and only adds tag churn. Run the stable Docs workflow only
+when the spec version itself is being released, or when the maintainer asks for
+it explicitly:
```bash
gh workflow run release.yml --ref main -f version=current
```
-`npm run deploy` uses the current native-derived `spec` value from
-`openiap-versions.json`. It rejects any explicit argument that differs from the
-native floor; docs deployment is not a version-bump path.
+Verifying a docs deployment: `llms-full.txt` carries a `Generated:` timestamp
+that must match the committed file, and the deployed entry bundle should contain
+any newly added page copy. A stale timestamp under a cache-busting query string
+means the deploy has not landed, not that a CDN is caching.
---
diff --git a/packages/kit/server/api/v1/in-flight-limit.ts b/packages/kit/server/api/v1/in-flight-limit.ts
index 17a16e076..a45dad699 100644
--- a/packages/kit/server/api/v1/in-flight-limit.ts
+++ b/packages/kit/server/api/v1/in-flight-limit.ts
@@ -45,6 +45,7 @@ const sharedVerifyState: InFlightState = {
type InFlightLimitVars = {
apiKeyHash?: string;
+ verifyCapacityRejected?: boolean;
};
/**
@@ -132,6 +133,11 @@ export function inFlightLimitMiddleware(
c.header("X-Concurrency-Limit", String(limit));
c.header("X-Concurrency-Remaining", "0");
c.header("X-Concurrency-Scope", scope);
+ // The replay guard runs before this middleware so cheap duplicate
+ // detection still happens before capacity accounting. Tell that outer
+ // guard that this request never received a verification slot, allowing
+ // it to refund the token consumed for this attempt.
+ c.set("verifyCapacityRejected", true);
return c.json(
{
errors: [
diff --git a/packages/kit/server/api/v1/replay-guard.integration.test.ts b/packages/kit/server/api/v1/replay-guard.integration.test.ts
new file mode 100644
index 000000000..632ff8a4a
--- /dev/null
+++ b/packages/kit/server/api/v1/replay-guard.integration.test.ts
@@ -0,0 +1,122 @@
+import { Hono } from "hono";
+import { describe, expect, test } from "vitest";
+
+import { inFlightLimitMiddleware, type InFlightState } from "./in-flight-limit";
+import { replayGuardMiddleware, type ReplayBucket } from "./replay-guard";
+import { verifyPurchaseInputSchema } from "./route-input-schemas";
+import { validator } from "./validator";
+
+interface TestVariables {
+ apiKeyHash: string;
+ verifyCapacityRejected?: boolean;
+}
+
+const verifyBody = {
+ store: "apple",
+ jws: `${"a".repeat(40)}.${"b".repeat(40)}.${"c".repeat(40)}`,
+} as const;
+
+function createApp(
+ store: Map503 SERVICE_BUSY{" "}
with Retry-After, X-Concurrency-Limit,{" "}
X-Concurrency-Remaining, and{" "}
- X-Concurrency-Scope.
+ X-Concurrency-Scope. Capacity-rejected attempts never reach
+ an upstream verification store and do not consume the per-payload replay
+ budget, so repeated backoff retries remain SERVICE_BUSY{" "}
+ instead of turning into DUPLICATE_PAYLOAD.
Self-hosters can tune VERIFY_MAX_IN_FLIGHT,{" "}