The gap
SqliteOfflineQueue::setIdempotencyKey (the protected hook IOfflineQueue::enqueue(payload, key)'s default implementation calls after the one-arg enqueue()) does a raw UPDATE ... SET idempotency_key = ?. If another row already holds that non-empty key, the partial unique index (ix_queue_idem) rejects the write and the call throws SqliteOfflineQueueError:
SqliteOfflineQueue: setIdempotencyKey failed: UNIQUE constraint failed: morph_offline_queue.idempotency_key
SqliteOfflineQueue's own two-arg enqueue(payload, key) override never reaches this — it resolves a conflict with INSERT ... ON CONFLICT ... DO NOTHING plus a lookup, returning the existing row's id exactly like FileOfflineQueue and matching the dedup contract discussed in #175. The throwing path is reachable only when a caller holds an IOfflineQueue& and explicitly scope-qualifies the call, e.g. q.IOfflineQueue::enqueue(payload, key), bypassing normal virtual dispatch to reach the base class's default (payload-then-stamp) implementation instead of the derived override.
Verified
Confirmed independently (twice) against include/morph/offline/sqlite_offline_queue.hpp (current master), via a standalone compiled probe (cl.exe /std:c++latest, vcpkg sqlite3 from build/cl-debug):
SqliteOfflineQueue q{dbPath};
q.enqueue("first", "DUPKEY");
q.enqueue("third", "OTHERKEY");
q.IOfflineQueue::enqueue("fourth-via-base", "DUPKEY"); // throws
derived enqueue dedup: id1=1 id2=1 (equal=yes) size=1
row id=1 payload="first" key="DUPKEY"
third (OTHERKEY) id3=3 size=2
base-qualified enqueue threw=yes what="SqliteOfflineQueue: setIdempotencyKey failed: UNIQUE constraint failed: morph_offline_queue.idempotency_key"
final size=3
base-qualified enqueue with fresh key: id5=5 (no throw)
This confirms:
SqliteOfflineQueue::enqueue(payload, key) (sqlite_offline_queue.hpp:172-213) resolves a conflicting key via INSERT ... ON CONFLICT ... DO NOTHING plus a lookup — a silent no-op dedup that discards the new payload and returns the existing row's id.
- The protected hook
setIdempotencyKey (sqlite_offline_queue.hpp:285-291) does a raw UPDATE, which the partial unique index ix_queue_idem rejects on the same conflict, throwing SqliteOfflineQueueError.
- This throw path is reachable only via an
IOfflineQueue& with an explicit scope qualifier (q.IOfflineQueue::enqueue(...)) — a fresh (non-conflicting) key through the same base-qualified path does not throw, isolating the throw to the conflict case specifically.
- No shipped call path reaches it today.
SyncWorker never calls enqueue directly (it only drains/replays), and setIdempotencyKey appears nowhere outside the three queue implementations and tests/test_file_offline_queue.cpp's own scope-qualified test for FileOfflineQueue. The scope-qualified call shape is exercised in-tree for the sibling implementation, just not for SqliteOfflineQueue's conflicting-key case.
- The asymmetry is
SqliteOfflineQueue-only: FileOfflineQueue::setIdempotencyKey (file_offline_queue.hpp:265-273) unconditionally overwrites the in-memory map entry with no unique constraint to conflict against, and InMemoryOfflineQueue::setIdempotencyKey never dedups at all — neither has this asymmetry.
Spec gap
docs/spec/offline/offline.md line 180 documents setIdempotencyKey only as the hook the default two-arg enqueue uses to stamp the key onto an already-enqueued item ("Default no-op; InMemoryOfflineQueue records the key directly instead."). Nothing there, in the IOfflineQueue table, the SqliteOfflineQueue section, or the dedup-contract note (offline.md ~line 774-775) mentions that the hook can throw on a conflict. This is a genuine spec gap (no documented contract to disagree with), not a spec-vs-code disagreement.
Direction
Make setIdempotencyKey resolve a conflicting key the same way SqliteOfflineQueue::enqueue(payload, key) already does: no-op, keep the existing row, matching the class's own dedup contract instead of throwing. This is the smaller change, removes the asymmetry outright rather than just documenting it, and costs nothing to callers — the hook is protected, has no public contract to preserve, and (per the verification above) no shipped code depends on the current throwing behavior.
The alternative — make the derived enqueue(payload, key) throw instead, to match the base hook — is possible but less consistent with current usage: it would break the existing dedup contract that enqueue(payload, key) callers already rely on (see #175), just to preserve a throw path nothing currently exercises.
Once the fix lands, docs/spec/offline/offline.md line 180 (and the dedup-contract note around line 774-775) should be updated to state the conflict behavior explicitly.
Scope
This is include/morph/offline/sqlite_offline_queue.hpp only — FileOfflineQueue::setIdempotencyKey has no unique-index equivalent to conflict against, and InMemoryOfflineQueue::setIdempotencyKey never dedups at all, so neither of the other two implementations has this asymmetry.
Split out of #175's "Out of scope" section during that issue's triage, per the triage-issue skill's guidance not to fold a second distinct problem into an existing issue.
The gap
SqliteOfflineQueue::setIdempotencyKey(the protected hookIOfflineQueue::enqueue(payload, key)'s default implementation calls after the one-argenqueue()) does a rawUPDATE ... SET idempotency_key = ?. If another row already holds that non-empty key, the partial unique index (ix_queue_idem) rejects the write and the call throwsSqliteOfflineQueueError:SqliteOfflineQueue's own two-argenqueue(payload, key)override never reaches this — it resolves a conflict withINSERT ... ON CONFLICT ... DO NOTHINGplus a lookup, returning the existing row's id exactly likeFileOfflineQueueand matching the dedup contract discussed in #175. The throwing path is reachable only when a caller holds anIOfflineQueue&and explicitly scope-qualifies the call, e.g.q.IOfflineQueue::enqueue(payload, key), bypassing normal virtual dispatch to reach the base class's default (payload-then-stamp) implementation instead of the derived override.Verified
Confirmed independently (twice) against
include/morph/offline/sqlite_offline_queue.hpp(currentmaster), via a standalone compiled probe (cl.exe /std:c++latest, vcpkg sqlite3 frombuild/cl-debug):SqliteOfflineQueue q{dbPath}; q.enqueue("first", "DUPKEY"); q.enqueue("third", "OTHERKEY"); q.IOfflineQueue::enqueue("fourth-via-base", "DUPKEY"); // throwsThis confirms:
SqliteOfflineQueue::enqueue(payload, key)(sqlite_offline_queue.hpp:172-213) resolves a conflicting key viaINSERT ... ON CONFLICT ... DO NOTHINGplus a lookup — a silent no-op dedup that discards the new payload and returns the existing row's id.setIdempotencyKey(sqlite_offline_queue.hpp:285-291) does a rawUPDATE, which the partial unique indexix_queue_idemrejects on the same conflict, throwingSqliteOfflineQueueError.IOfflineQueue&with an explicit scope qualifier (q.IOfflineQueue::enqueue(...)) — a fresh (non-conflicting) key through the same base-qualified path does not throw, isolating the throw to the conflict case specifically.SyncWorkernever callsenqueuedirectly (it only drains/replays), andsetIdempotencyKeyappears nowhere outside the three queue implementations andtests/test_file_offline_queue.cpp's own scope-qualified test forFileOfflineQueue. The scope-qualified call shape is exercised in-tree for the sibling implementation, just not forSqliteOfflineQueue's conflicting-key case.SqliteOfflineQueue-only:FileOfflineQueue::setIdempotencyKey(file_offline_queue.hpp:265-273) unconditionally overwrites the in-memory map entry with no unique constraint to conflict against, andInMemoryOfflineQueue::setIdempotencyKeynever dedups at all — neither has this asymmetry.Spec gap
docs/spec/offline/offline.mdline 180 documentssetIdempotencyKeyonly as the hook the default two-argenqueueuses to stamp the key onto an already-enqueued item ("Default no-op;InMemoryOfflineQueuerecords the key directly instead."). Nothing there, in theIOfflineQueuetable, theSqliteOfflineQueuesection, or the dedup-contract note (offline.md~line 774-775) mentions that the hook can throw on a conflict. This is a genuine spec gap (no documented contract to disagree with), not a spec-vs-code disagreement.Direction
Make
setIdempotencyKeyresolve a conflicting key the same waySqliteOfflineQueue::enqueue(payload, key)already does: no-op, keep the existing row, matching the class's own dedup contract instead of throwing. This is the smaller change, removes the asymmetry outright rather than just documenting it, and costs nothing to callers — the hook is protected, has no public contract to preserve, and (per the verification above) no shipped code depends on the current throwing behavior.The alternative — make the derived
enqueue(payload, key)throw instead, to match the base hook — is possible but less consistent with current usage: it would break the existing dedup contract thatenqueue(payload, key)callers already rely on (see #175), just to preserve a throw path nothing currently exercises.Once the fix lands,
docs/spec/offline/offline.mdline 180 (and the dedup-contract note around line 774-775) should be updated to state the conflict behavior explicitly.Scope
This is
include/morph/offline/sqlite_offline_queue.hpponly —FileOfflineQueue::setIdempotencyKeyhas no unique-index equivalent to conflict against, andInMemoryOfflineQueue::setIdempotencyKeynever dedups at all, so neither of the other two implementations has this asymmetry.Split out of #175's "Out of scope" section during that issue's triage, per the triage-issue skill's guidance not to fold a second distinct problem into an existing issue.