What happened
#180's rehearsal 4 (key rotation) asks for one thing above the others: that "a rotation performed in the wrong order is refused rather than silently invalidating history." For the reputation issuer key it is not refused, and it does silently invalidate history.
OpenAgents.Reputation.retire_key/2 takes any DateTime:
def retire_key(%SigningKey{} = key, retired_at \\ DateTime.utc_now()) do
key |> SigningKey.retire_changeset(retired_at) |> Repo.update()
end
SigningKey.retire_changeset/2 casts and requires retired_at and validates nothing else — not against activated_at, not against any attestation the key has signed.
The verdict predicate then reads:
report["digest_match"] and report["signature"]["valid"] and
report["signature"]["key_active_at_attestation"] == true and ...
key_active_at_attestation comes from SigningKey.active_at?/2, a half-open window [activated_at, retired_at). So retiring a key at a timestamp at or before an existing attestation's attested_at flips that attestation to "verified" => false while "signature" => %{"valid" => true}.
A valid signature over an unaltered claim, reported as unverified, by a single UPDATE against a row the operator controls. Nothing refuses it and nothing logs it.
Why it matters
docs/forge-exit-rehearsals.md rehearsal 4 asserts the good half of this today: "attestations signed by the retired key stay verifiable against the admitted public key, which is why the key is admitted rather than assumed." That is true for the forward order, which test/openagents/reputation_test.exs:231 already proves. The backward order is the one an operator would reach for while trying to disown something, and it is unguarded.
The asymmetry is worth stating: the forward order is enforced. require_active_key/2 refuses issuance under a retired key with {:error, :signing_key_retired}. Only the retirement edge is open.
Reproduction
Admit a key, issue an attestation, then retire the key with a retired_at earlier than the attestation's attested_at. Reputation.verify/2 returns "verified" => false, "signature" => %{"valid" => true, "key_status" => "retired", "key_active_at_attestation" => false}.
Pinned by test/openagents/reputation/key_rotation_test.exs, which records the current behaviour and names this issue, so a fix turns the test red rather than passing unnoticed.
Acceptance criteria
retire_key/2 refuses a retired_at at or before the newest attestation the key signed, or the retirement is allowed and the attestations it unverifies are reported rather than silently changed.
- Whichever way it lands, the ordering rule is stated in
docs/forge-exit-rehearsals.md rehearsal 4 rather than assumed.
Found by: #180's rehearsal 4, docs/forge-exit-rehearsals.md.
What happened
#180's rehearsal 4 (key rotation) asks for one thing above the others: that "a rotation performed in the wrong order is refused rather than silently invalidating history." For the reputation issuer key it is not refused, and it does silently invalidate history.
OpenAgents.Reputation.retire_key/2takes anyDateTime:SigningKey.retire_changeset/2casts and requiresretired_atand validates nothing else — not againstactivated_at, not against any attestation the key has signed.The verdict predicate then reads:
key_active_at_attestationcomes fromSigningKey.active_at?/2, a half-open window[activated_at, retired_at). So retiring a key at a timestamp at or before an existing attestation'sattested_atflips that attestation to"verified" => falsewhile"signature" => %{"valid" => true}.A valid signature over an unaltered claim, reported as unverified, by a single
UPDATEagainst a row the operator controls. Nothing refuses it and nothing logs it.Why it matters
docs/forge-exit-rehearsals.mdrehearsal 4 asserts the good half of this today: "attestations signed by the retired key stay verifiable against the admitted public key, which is why the key is admitted rather than assumed." That is true for the forward order, whichtest/openagents/reputation_test.exs:231already proves. The backward order is the one an operator would reach for while trying to disown something, and it is unguarded.The asymmetry is worth stating: the forward order is enforced.
require_active_key/2refuses issuance under a retired key with{:error, :signing_key_retired}. Only the retirement edge is open.Reproduction
Admit a key, issue an attestation, then retire the key with a
retired_atearlier than the attestation'sattested_at.Reputation.verify/2returns"verified" => false,"signature" => %{"valid" => true, "key_status" => "retired", "key_active_at_attestation" => false}.Pinned by
test/openagents/reputation/key_rotation_test.exs, which records the current behaviour and names this issue, so a fix turns the test red rather than passing unnoticed.Acceptance criteria
retire_key/2refuses aretired_atat or before the newest attestation the key signed, or the retirement is allowed and the attestations it unverifies are reported rather than silently changed.docs/forge-exit-rehearsals.mdrehearsal 4 rather than assumed.Found by: #180's rehearsal 4,
docs/forge-exit-rehearsals.md.