The first two pieces were about stopping things — put the boundary on the execution path, then give every tool an execution policy by risk.
But so...
For further actions, you may consider blocking this person and/or reporting abuse
The group key being the idempotency base key makes retries converge on one identifier. Membership is a separate question. If the tool is nondeterministic and a retry writes twelve rows where the first call wrote eleven, the extra row lands under the same group key, and whether it gets recorded depends on whether you keep the first declaration or union in the later one. Keeping the first is the defensible idempotency answer. It also reproduces §3 precisely: revoke the group, one row survives, the UI says revoked. What is interesting is that §6's boundary stops holding there. The tool declared its effects honestly on both calls, so the recording gap traces back to your own idempotency rule rather than to a contract defect on the tool's side.
Separately,
compensatinghas no age.noneis honest in a durable way because it never changes.compensatingis honest at the instant the request left, and silent after that, so with no reconciliation the resting state of a failed external compensation is "still in progress" indefinitely. Per row the UI stays truthful. The aggregate does not. Ask what is currently unresolved and an entry stuck since last month reads identically to one from five minutes ago. Your four closing tests all probe the moment of writing. A fifth could probe staleness, and stamping the request time then flagging anything past a threshold costs far less than polling or callbacks.On the first point: do you keep the first declaration and surface a retry mismatch as a conflict, or merge newly declared members into the group before a revoke is allowed to report complete?
You're right on the retry, and right in a way that breaks §6 rather than sitting inside it.
We keep the first declaration, and we do it silently. The group key is a hash of user, conversation, tool and sorted arguments, so a real retry lands on the same key and the second declaration collides on insert. The insert is one transaction for the whole group, so it rolls back and we replay what's already stored. Nothing compares the two declarations. If the retry writes twelve rows after the first call wrote eleven, the twelfth is dropped and nobody is told the retry disagreed.
§6 says any recording gap traces to a contract defect on the tool's side. That holds when the tool under-declares. It doesn't hold here, and I hadn't considered a tool that declares honestly twice and disagrees with itself. Your version of the consequence is right too, down to the shape: revoke the group, one row survives, the UI says revoked. §3's failure, produced by §6's fix.
So the answer to your question is the bad one. Keep-first, no conflict surfaced, which is the first of your two. The second is the one I'd take. Keeping the first declaration is correct for keeping the write idempotent; it isn't enough for a revoke claim, because a revoke that reports complete has to be able to say the retry declared more than it holds. Neither is in the code yet.
The age point is also correct, and there's nothing to check. The row carries a created_at and a revoke_status, and no stamp for when the compensation request left. So none is durable because it's a classification, while compensating is a photograph of one instant with no expiry. Row by row it stays honest. Ask the aggregate what's unresolved and an entry stuck since last month reads the same as one from five minutes ago.
Stamping the request time plus a threshold is cheaper than anything I had. Polling or callbacks buy reconciliation; a timestamp buys the question "is this stale", which is most of what you'd want from it. The four in the closing section all probe the moment of writing, as you say. I'd add the fifth.
The same key has a mirror failure. It collides only when the argument bytes match after sorting, and plenty of retries are not byte-identical. A reissue that carries a continuation token or a refreshed id in the payload hashes differently while pointing at the same effect. Conversation is in the hash too, so a resumed session can split it without the arguments changing at all. Then the effect lives in two groups. Revoke one and the other survives, and this time there is nothing anomalous to find: no collision, and each group internally consistent and complete on its own terms. Under-recording at least leaves a conflict somewhere in the record. Over-splitting leaves none.
The check for the collision case is already sitting at a site you have. The unique-constraint violation is a comparison; the replay path just throws its result away. At the rollback point the stored set and the rejected declaration are both in hand. A set difference there costs no new pass. Run it in both directions, since a retry can also declare fewer rows than the first call did. If they disagree, keep the rejected declaration and mark the group disputed, so a later revoke cannot report complete just because it drained the rows that happened to be stored first.
On the timestamp, where you put it decides what it can answer. Stamp on return from the outbound call and a process that dies mid-call leaves a row reading as though compensation was never requested at all. Write the intent before sending, record the acknowledgment as its own event. The window between them is a genuine unknown, and it is a different unknown from sent-and-never-answered: one may never have reached the external system, the other definitely did. A single staleness threshold over both collapses that distinction into one bucket.
Which pushes the question back onto the key itself. Should the group identity derive from a stable identity for the declared effect, with the call arguments kept alongside as evidence? Revocation has to cover the effect, and the arguments are only a guess at which effect it was.
That's worse than the case I admitted to, and right about why. Over-splitting leaves nothing to find.
The key is a hash of user, conversation, tool and sorted arguments, so any of those moving splits the group. Conversation is the one that moves without the tool doing anything wrong: we create a new conversation when the id is missing, and also when the id doesn't resolve to one the caller may read. So a resumed session can put the same effect into two groups, each complete on its own terms, with no collision anywhere. Revoke one and the other survives, and nothing in the record reads as anomalous. Under-recording at least argues with itself, as you put it.
The check is where you say it is. In the collision branch both sets are in scope already: the declaration that was rejected, and the group that's stored. A difference there is a comparison, not another pass, and it has to run both ways since a retry can also declare less. Keeping the rejected declaration and marking the group disputed is better than what I wrote. "No conflict surfaced" was doing a lot of work in that sentence, and disputed is the part that stops a revoke reporting complete. I had those as one thing and they aren't.
On the timestamp, also right, and we are a bucket short rather than one bucket too many. We write compensating after the outbound call returns. A process that dies mid-call writes nothing at all, so the row reads as though compensation was never requested. That isn't collapsing two unknowns into one threshold, it's losing one of them. Intent before the send, acknowledgment as its own event, and the gap between them kept visible, is the shape.
Your last question is the one I can't answer from here. Effect identity rather than call identity looks right, and it's a smaller change than it sounds: our per-member keys already carry result type and result id, so the members are keyed on the effect. The group key is the one built from the call, and arguments are already kept beside it as args_hash, which would leave them as evidence. What I can't settle is whether keying the group on the principal effect would merge two legitimate calls that touch the same row. That may be the right answer and it may be a new way to be wrong, and I'd rather say so than pick one.
Merging is a real risk and it fails in the other direction. Under-recording and splitting both end with effects alive after a revoke reports complete. Merging ends with a revoke reaching effects nobody asked it to reach. That one does not come back. You can run a revoke twice, but you cannot run it backwards, so swapping the key trades a recoverable failure for an unrecoverable one. Reason enough on its own not to swap it.
The two keys are answering different questions anyway. The call key answers whether this is the same request. The effect key answers what an undo touches. Those do not have to live in the same field, and you already hold both. Keep the group keyed on the call so idempotency stays where it is, and add an index running the other way, from effect identity to the groups claiming it.
Splitting then becomes visible without the key moving. At revoke time, for each member, ask which other live groups name that same effect. If one does, the revoke cannot report complete, and it lands in the disputed state you already agreed to build, entered from a second site. What I said earlier was too strong. Splitting leaves nothing at write time. It leaves plenty at read time, because the group that survived is still pointing at the effect you just undid. Every check in this thread so far has been a write-time check. This is the one that cannot be.
The qualification is the thing you were right to hesitate over. Whether two legitimate calls merge depends on what the effect identity names. Two updates to row 47 are two effects against one target. Effect identity has to carry the target and the change together. Result type plus result id sounds like it carries the target and stops there. If that is what it is, an index over those keys tells you two groups touched the same row and nothing about whether they made the same change, which is the merge you were afraid of arriving through the index instead of through the key. Is there anything in a member row today that separates two writes to the same target?
Yes, and the field that carries the change is optional, which is a worse answer than it looks.
A member row holds before_snapshot and after_snapshot next to result_type and result_id. That pair is the change itself, so identity in your sense, target plus change, is already the shape of the data. It is nullable: captured when a snapshot captor is wired for the write, null otherwise. So it can carry the change but cannot be the identity today, because an identity cannot be optional.
args_hash is the other candidate and it is always populated, which makes it the tempting one. It is also circular and wrong. It fingerprints the request, and it is an input to the group key. Under a nondeterministic tool two calls making the same change produce different argument bytes, which is the splitting you found. So the always-present field is the one that disagrees with itself about sameness, and the field that agrees might be missing. tool_name and created_at separate rows as well, but neither names the change.
You are right that swapping is the wrong trade, and I had that as an open question rather than an answer. Undoing twice is survivable; reaching an effect nobody asked you to reach is not. Those are not symmetrical, and keeping the call key with an index the other way settles it.
On timing, agreed, and I think it is the sharper half of what you sent: at write time each group is internally correct, so there is nothing there to be wrong. The check has no subject until a revoke is in flight. We added a scan today that answers which business objects span more than one group, so the overlap is at least visible without the key moving. But a scan is not a gate. Nobody is compelled to run it. Your version puts the question inside the revoke, so one that would report complete has to look first, and lands in disputed when another live group names the same effect. The disputed state exists as of today, entered from the collision site. Visible and load-bearing are different things, and we have the first one.