snapshotSignedPolicies Guard Blocks Contract Resign
This article may contain affiliate links. Its content is not affected by advertising.
In short
A one-way snapshot guarded by 'write only if both columns are null' is correct on its own, but if nothing resets those columns to null, the snapshot stays frozen for the row's life.
Conclusion
A guard that writes only when two columns are both still null is correct for a one-way function — but that correctness quietly assumes something else resets those columns back to null later. A function that snapshots a contract’s policy version at signing time was built exactly this way. Once a contract is signed, the snapshot columns are populated and the guard’s condition (both null) can never be satisfied again. That part of the design is fine. The problem was that no code anywhere ever set those columns back to null, so the snapshot could never be refreshed.
This surfaced as: revising the terms and inserting a contract-specific version had no effect on contracts that were already signed — the agreed content stayed frozen at whatever version existed when the signature happened. The admin UI even displayed a message telling staff to “obtain re-consent,” but no code existed to carry out the action the message described.
The failure mode
- Contracts can have a contract-specific version of the terms and posting standards, layered on top of the company-wide default.
- Inserting a contract-specific version into an already-signed contract had no visible effect: the application form and whatever counted as “agreed” stayed on the version that was in effect at signing time.
- The signed contract’s admin card showed a note: “The signed version is preserved as a record. To make the dedicated version the official agreement, please obtain re-consent via the web.”
- But there was no button or link anywhere near that note — nothing that actually started a re-consent flow.
Cause
The function that snapshots policy versions at signing time was written like this:
// src/lib/policies.ts (03a8cd300b, lines 107-123)
export async function snapshotSignedPolicies(contractId: string): Promise<void> {
// Freeze the effective version (contract-specific if one exists), not the default (0060).
const current = await getEffectivePolicies(contractId);
const terms = current.terms?.id ?? null;
const ps = current.posting_standards?.id ?? null;
if (!terms && !ps) return;
const admin = createSupabaseAdminClient();
await admin
.from("contracts")
.update({
signed_terms_doc_id: terms,
signed_posting_standards_doc_id: ps,
})
.eq("id", contractId)
.is("signed_terms_doc_id", null)
.is("signed_posting_standards_doc_id", null);
}
The two .is(..., null) conditions mean this UPDATE only ever matches a contract where both columns are still null. That’s a deliberate idempotency guard against double-signing or an accidental re-run overwriting a value. Once a contract is signed and both columns get populated, calling this function again on the same contract matches zero rows in the WHERE clause. Nothing errors. It silently becomes a no-op.
That part is correct. The actual problem is that nothing in the pre-fix codebase ever provided the path back through the guard — a way to reset those columns to null. The admin UI’s signed-contract card carried this note:
// src/app/admin/contracts/[id]/ContractPolicyCard.tsx
// (pre-fix: c8e05e29d620209109cb742fcaee5a299cc16a58, lines 198-203)
{isSigned && (
<div className="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-800">
This contract is <strong>signed</strong>. The version in effect at signing remains frozen and displayed as a record.
To make the inserted dedicated version the <strong>official agreement</strong>, please obtain re-consent via the web.
</div>
)}
This copy assumes “re-consent fixes it.” But for re-consent to work at all, signed_terms_doc_id and signed_posting_standards_doc_id first have to go back to null so snapshotSignedPolicies’s guard can pass again. That null-resetting step was never implemented. The note pointed at a feature that didn’t exist.
The fix
The fix doesn’t touch the guard inside snapshotSignedPolicies at all. The guard stays exactly as it was. Instead, a dedicated action was added whose only job is to reset the columns to null and put the contract back into an unsigned state:
// src/app/admin/contracts/[id]/policy-actions.ts (03a8cd300b, lines 163-227)
/**
* Reset a signature so the contract goes back to "awaiting re-consent" after a terms revision (0060).
*
* A signed application form is frozen at its signed_*_doc_id snapshot, so inserting a dedicated
* version later has no visible effect. This action clears the snapshot and reverts application_status
* to "awaiting signature," letting the advertiser re-consent via the application link. On re-consent,
* snapshotSignedPolicies re-snapshots the effective version (dedicated version takes priority) —
* that's what makes the revised version the official agreement.
*/
export async function requestPolicyReconsent(
_prev: ContractPolicyState,
formData: FormData
): Promise<ContractPolicyState> {
await requireStaff();
const contractId = String(formData.get("contract_id") ?? "");
if (!UUID.test(contractId)) return { error: "契約が不正です。" };
const supabase = await createSupabaseServerClient();
const { data: contract } = await supabase
.from("contracts")
.select("id, application_status")
.eq("id", contractId)
.maybeSingle();
if (!contract) return { error: "契約が見つかりません。" };
if (contract.application_status !== "署名済") {
return { error: "署名済みの契約のみ再同意を依頼できます。" };
}
const { data: reset } = await supabase
.from("contracts")
.update({
application_status: "署名依頼中",
signed_terms_doc_id: null,
signed_posting_standards_doc_id: null,
signed_date: null,
agreed_at: null,
agreed_name: null,
})
.eq("id", contractId)
.eq("application_status", "署名済")
.select("id");
if (!reset || reset.length === 0) {
return { error: "署名のリセットに失敗しました。" };
}
await logAudit({
action: "update",
entity: "contracts",
entityId: contractId,
summary: "規約改定のため署名をリセット(再同意待ちへ)",
metadata: { reason: "policy_reconsent" },
});
revalidatePath(`/admin/contracts/${contractId}/application`);
return {
success:
"署名をリセットし、再同意待ちにしました。申込書リンクを広告主へ再送してください(再同意で改定版が署名時版として確定します)。",
};
}
(application_status is stored as Japanese enum text in this codebase — "署名済" is “signed,” "署名依頼中" is “awaiting signature.”) By explicitly resetting signed_terms_doc_id and signed_posting_standards_doc_id to null, the next time the advertiser re-consents through the application link, snapshotSignedPolicies’s guard passes again and snapshots whatever effective version (dedicated version first, default otherwise) is in force at that moment. The guard itself was never touched. What was missing — and what got added — was the one step that lets the guard be passed a second time, built by someone else, for a reason (a terms revision) that had nothing to do with the original author’s intent.
The reset discards what version the contract had originally been signed under, so logAudit records it with metadata.reason: "policy_reconsent", and the UPDATE also requires application_status to currently be "署名済" (signed) via its .eq() clause, guarding against resetting a contract that was never signed in the first place.
The generalizable lesson
An idempotency guard of the form “write only if both values are still unset” is correct for a one-way process. But that correctness implicitly depends on someone, someday, building a path that resets the values back to unset. Whoever wrote the guard was thinking only about preventing a double-run — not about the day someone else needs this guard to fire a second time.
As a result, the code that resets the guard tends to get built later, by a different person, for a different reason (here: responding to a terms revision) than whatever motivated the original guard. When that need arrives, searching the codebase for it turns up nothing — what turns up instead is only a UI message telling the user the action is possible. That message creates an expectation the implementation hasn’t caught up to, which, until the gap is noticed, actively delays discovery rather than helping.
Any UPDATE guarded by .eq(col, value) or .is(col, null) deserves one extra question: does a path exist anywhere in the codebase for this condition to become true again? If it doesn’t, that UPDATE is effectively a once-in-the-row’s-lifetime operation, and the reset path should be built before any UI copy promises it’s possible.
Frequently asked questions
Q1Why doesn't a dedicated policy version become official on an already-signed contract?
The signed version is snapshotted into columns like signed_terms_doc_id; all later display and billing reference that snapshot. snapshotSignedPolicies only writes when both columns are null, so a signed contract is never overwritten — inserting a dedicated version doesn't update the snapshot.
Q2There was a 'please re-consent' message in the UI — why didn't anyone notice it was broken?
The message sat permanently on the signed-contract card, but no code behind it ever reset the signature. It was written on the assumption the action existed. Nobody noticed until someone actually inserted a dedicated version and tried to trigger re-consent.
Q3Did the fix change the idempotency guard in snapshotSignedPolicies itself?
No. The guard is unchanged. A new action, requestPolicyReconsent, was added to reset a signed contract's status and explicitly null out the snapshot columns, so the guard can fire again the next time the advertiser re-consents.
Environment verified
- Next.js 16.2.7 / @supabase/supabase-js 2.106.2 / React 19.2.4
- Found 2026-06-25 in a second agent's review pass, fixed same day
What this article is based on
- TypeScript file lines 107-123commit 03a8cd3
- TypeScript file lines 163-227commit 03a8cd3
- TypeScript file lines 198-203commit c8e05e2
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.