Skip to content

feat(items): idempotency keys for item create (TASK-3585) - #2065

Merged
xarmian merged 1 commit into
mainfrom
rook/task-3585-create
Oct 11, 2026
Merged

xarmian merged 1 commit into
mainfrom
rook/task-3585-create

Conversation

@xarmian

@xarmian xarmian commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

TASK-3585, create: idempotency keys for item create. This builds on #2062 (patch keys plus the capability gate).

What changes

  • Server: POST /workspaces/{ws}/collections/{c}/items accepts op_key in the body or an Idempotency-Key header. The key is claimed before the write, and the answer (the created item's id) is stored inside the create's own transaction (ItemCreate.AfterWriteTx, run by createItemTxWithID). A retry whose first answer was lost therefore gets the item back, as it stands now, with Idempotent-Replayed: true, and no twin is created.
    • A refused create releases its key.
    • A key reused for a different create returns 422 idempotency_key_reused.
    • A key that is still in flight returns 409 idempotency_in_flight.
    • A replay whose item has since been deleted returns 410 idempotency_target_gone.
    • Shared claim helper: internal/server/idempotency_keys.go, for append, copy and push next.
  • CLI: pad item create --op-key, with a fresh key per invocation by default. On a 5xx or transport error it retries once with the same key, and only when the server advertises idempotency_keys. An older server ignores the key, so a retry there could create a twin.
  • MCP: pad_item create accepts op_key on both transports. Remote maps it into the body; stdio passes --op-key through the generic flag mapping. in_flight and key_reused are already structured for any route: remote via errors.go, stdio via main.go's top-level marker. ToolSurfaceVersion goes from 0.90 to 0.91 (additive). The golden files, README and instructions.md are updated.

Known gap

idempotency_target_gone (410) is not on the structured allow-list, so it reaches MCP as a generic error. The version.go changelog says so.

Tests

  • cmd/pad/task3585_create_retry_test.go: same-key retry, no retry without the capability, no retry on a 4xx, a key that is either the one given or a fresh one each time. The no-capability test fails with the gate removed.
  • internal/server/task3585_create_key_test.go: replay returns the same item with no twin, a refusal releases the key, the body op_key replays.
  • internal/mcp/task3585_op_key_test.go::TestTASK3585_MCPCreateOpKey: fails if op_key is dropped from mapItemCreate.
  • Local: full internal/server and internal/materialize packages green (after rebuilding this worktree's bundles), plus cmd/pad, internal/cli, internal/mcp, internal/models and the repo guard tests on the rebased head. Lint is clean.

https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

A create carries op_key (body) or Idempotency-Key (header). The key is
claimed before the write and its answer (the created item's id) is stored
inside the create's own transaction, so a retry whose first answer was
lost returns that item instead of creating a twin. A refused create
releases its key; a key reused for a different create is 422
idempotency_key_reused; one still in flight is 409 idempotency_in_flight.
A replay whose item has since been deleted answers 410
idempotency_target_gone.

CLI: `pad item create --op-key`, a fresh key per invocation by default,
and one same-key retry of a 5xx or transport error, only when the server
advertises idempotency_keys. MCP: pad_item create accepts op_key on both
transports (remote maps it into the body; stdio passes --op-key).
ToolSurfaceVersion 0.90 -> 0.91 (additive).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
@xarmian
xarmian merged commit c46ecc4 into main Oct 11, 2026
12 checks passed
@xarmian
xarmian deleted the rook/task-3585-create branch October 11, 2026 17:06
xarmian added a commit that referenced this pull request Oct 12, 2026
… (TASK-3585) (#2088)

Retro CONVE-735 finding on #2065. handleCreateItem enforced the workspace
item limit before claiming the idempotency key, so a create that
succeeded AT the limit and lost its answer could not replay: its retry met
the limit its own success had reached. The claim now comes first; the
request is not modified in between, so the key's hash is unchanged, and
every refusal after it (the limit included) still releases the key.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant