provider_store_service, search_tool_store_service and guardrail_store_service share one shape, and two review findings on #1120 are really findings about that shape. Both apply to all three equally, which is why neither belongs in a PR that touches only the newest one.
Transaction ownership
src/gateway/AGENTS.md says "Services own commits and rollbacks." These three do not: save_* and delete_* stage mutations and say so in their docstrings ("staged; caller commits"), leaving the route to call db.commit() and to map an IntegrityError to a 409.
There is a reason it ended up this way. The route needs the commit to fail in its own hands so it can turn a primary-key collision into a 409 with a resource-specific message, and it needs the write and the cache refresh that follows to be ordered. But the convention and the code disagree, and right now the code wins three times over. Either the services should own their transactions and hand back something a route can turn into a 409, or the convention should record this as the deliberate exception it has become.
Re-encryption without a version check
reencrypt_* reads every row holding a secret, decrypts, re-encrypts and lets the ORM flush. Nothing pins the write to the ciphertext that was read. A PATCH committing between the read and the flush is overwritten with a re-encryption of the value it replaced.
The window is small and the operation is rare, run by hand during a key rotation. It is also not guarded at all today: with_for_update() is not used on this path, and SQLite would not emit FOR UPDATE if it were. A conditional update matching the original ciphertext, treating zero rows as "someone else got there first", would close it.
Why this was deferred
Raised in review on #1120: #1120 (comment) and #1120 (comment)
Changing one store to answer them would leave three stores with two shapes, which is worse than three stores with one flawed shape: the next person copies whichever they find first. Both points want a single change across all three.
provider_store_service,search_tool_store_serviceandguardrail_store_serviceshare one shape, and two review findings on #1120 are really findings about that shape. Both apply to all three equally, which is why neither belongs in a PR that touches only the newest one.Transaction ownership
src/gateway/AGENTS.mdsays "Services own commits and rollbacks." These three do not:save_*anddelete_*stage mutations and say so in their docstrings ("staged; caller commits"), leaving the route to calldb.commit()and to map anIntegrityErrorto a 409.There is a reason it ended up this way. The route needs the commit to fail in its own hands so it can turn a primary-key collision into a 409 with a resource-specific message, and it needs the write and the cache refresh that follows to be ordered. But the convention and the code disagree, and right now the code wins three times over. Either the services should own their transactions and hand back something a route can turn into a 409, or the convention should record this as the deliberate exception it has become.
Re-encryption without a version check
reencrypt_*reads every row holding a secret, decrypts, re-encrypts and lets the ORM flush. Nothing pins the write to the ciphertext that was read. A PATCH committing between the read and the flush is overwritten with a re-encryption of the value it replaced.The window is small and the operation is rare, run by hand during a key rotation. It is also not guarded at all today:
with_for_update()is not used on this path, and SQLite would not emitFOR UPDATEif it were. A conditional update matching the original ciphertext, treating zero rows as "someone else got there first", would close it.Why this was deferred
Raised in review on #1120: #1120 (comment) and #1120 (comment)
Changing one store to answer them would leave three stores with two shapes, which is worse than three stores with one flawed shape: the next person copies whichever they find first. Both points want a single change across all three.