フェイルセーフなスイーパー、フラグ優先順位、認証の同一性
はじめに
物理的な扉を制御するシステムにおいて、本当に恐ろしいバグは派手なものではありません。クラッシュしたリクエストはリトライされますし、500 エラーはページャーを鳴らします。信頼を蝕むのは 静かな バグです。午前 3 時に有料予約をキャンセルしてしまうバックグラウンドジョブ、黙って false と評価される認可ルール、2 つのタイムスタンプが同じ瞬間を別の表記で綴っているせいでイベントを誤った順序に並べてしまうタイムライン、といったものです。
本記事では、UnlockOS SDK の直近のイテレーションで適用した一連のエンジニアリングパターンを紹介します。ここで扱うパターンはすべて一般化可能です。バックグラウンドの書き込み処理を不変条件で守ること、優先順位のルールを決定的にすること、認証ステートの参照同一性を保つこと、そして画面を真っ白にする代わりに回復することです。
1. 時刻は「瞬間」であって「文字列」ではない
ゲスト向けタイムラインでメッセージの順序が誤って表示されていました。根本原因は、ISO-8601 文字列を辞書順で比較していたことでした。
// ❌ Lexicographic comparison of ISO strings
items.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));これは、2 つの生成元が同じ瞬間を異なる表記で綴るまでは正しく動作します。
| String | Instant |
|---|---|
2026-08-09T22:51:56+09:00 |
1786… |
2026-08-09T13:51:56.000Z |
identical |
2026-08-09T13:51:56Z |
identical |
3 つはすべて同じ瞬間です。しかしテキストとしてソートすると、バラバラに散らばります。監査ログやアクセスイベントのタイムラインにおいて「物事が起きた順序」は見た目の問題ではなく、証跡そのものです。
修正方法は、境界で瞬間(instant)へ正規化し、パースできない入力に対しては大きな声で失敗させることです。
export function toInstant(value: string): number {
const ms = Date.parse(value);
if (Number.isNaN(ms)) {
throw new TypeError(`Invalid timestamp: ${value}`);
}
return ms;
}
export function byInstantAsc<T>(
getTime: (item: T) => string,
getId: (item: T) => string,
) {
return (a: T, b: T): number => {
const delta = toInstant(getTime(a)) - toInstant(getTime(b));
// Stable tie-break: identical instants must still have a total order
return delta !== 0 ? delta : getId(a).localeCompare(getId(b));
};
}ソート処理そのもの以外に、2 つの細部が重要です。
- 決定的なタイブレーク。 同じミリ秒に書き込まれたイベントがレンダリングのたびに並び替わってはいけません。さもないと UI の差分計算やスナップショットテストが不安定になります。
- 境界での branded types。 値がバリデーション済みであるなら、それを型に埋め込み、生のユーザー入力と混同できないようにします。
declare const brand: unique symbol;
export type Instant = number & { readonly [brand]: 'Instant' };
export const parseInstant = (value: string): Instant => toInstant(value) as Instant;経験則: 文字列は転送のため、instant はロジックのためのものです。シリアライズ形式を比較演算子に漏らしてはいけません。
2. ライフサイクル状態の導出関数は 1 つだけ
滞在(stay)にはライフサイクルがあります。reserved → checked-in → checked-out であり、キャンセルと no-show が終端の分岐です。3 つの画面(ホストアプリ、管理コンソール、課金ジョブ)がそれぞれ生のカラムからこのステータスを計算していれば、必ず ずれます。そしてドアパネルと請求書とで、その人が部屋の中にいるかどうかの見解が食い違うことになります。
解決策は、網羅的に型付けされた単一の純粋な導出関数です。
export type StayStatus =
| 'reserved'
| 'checked_in'
| 'checked_out'
| 'cancelled'
| 'no_show';
export interface StayFacts {
readonly cancelledAt: Instant | null;
readonly checkedInAt: Instant | null;
readonly checkedOutAt: Instant | null;
readonly startsAt: Instant;
readonly endsAt: Instant;
}
export function deriveStayStatus(facts: StayFacts, now: Instant): StayStatus {
if (facts.cancelledAt !== null) return 'cancelled';
if (facts.checkedOutAt !== null) return 'checked_out';
if (facts.checkedInAt !== null) return 'checked_in';
if (now > facts.endsAt) return 'no_show';
return 'reserved';
}ガード句の並び順 こそ が仕様です。キャンセルがすべてに優先し、物理的な事実(チェックイン/アウト)が時計から推測した値に優先し、no_show は常に推論されるだけで、真実として保存されることはありません。
この関数は純粋かつ全域関数なので、テーブルテストで安価に固定できます。
describe('deriveStayStatus', () => {
const base = { startsAt: t('10:00'), endsAt: t('18:00') } as const;
it.each([
['cancelled beats everything', { ...base, cancelledAt: t('09:00'), checkedInAt: t('10:05') }, 'cancelled'],
['checked-out beats checked-in', { ...base, checkedInAt: t('10:05'), checkedOutAt: t('11:00') }, 'checked_out'],
['expired without arrival is no_show', { ...base }, 'no_show'],
])('%s', (_name, facts, expected) => {
expect(deriveStayStatus(normalize(facts), t('19:00'))).toBe(expected);
});
});state machine はライブラリである必要はありません。入力に対して全域であり、優先順位が書き下されている 1 つの 関数であればよいのです。
3. バックグラウンドのスイーパーには WHERE 句だけでなく不変条件が必要
TTL スイープジョブは、放棄された予約ホールドを解放します。これはまさに、無人で動き、金銭に触れ、誰も見ていないというタイプのジョブです。そしてこれが前払い済みの予約をキャンセルしてしまいました。
元の述語は status = 'reserved' AND expires_at < now() にマッチするものでした。これは 時間 の記述であって、安全性 の記述ではありません。前払い済みの予約は、ホールド期間を過ぎても正当に reserved のまま留まり得ます。
堅牢化したバージョンは、すべての安全条件を明示的に、かつ同じ場所にまとめます。
UPDATE reservations
SET status = 'cancelled',
cancel_reason = 'ttl_sweep',
cancelled_at = now()
WHERE status = 'reserved'
AND hold_expires_at < now()
AND payment_state = 'unpaid' -- never touch authorized/captured money
AND checked_in_at IS NULL -- never cancel someone already inside
AND created_at < now() - interval '10 minutes' -- grace for in-flight checkout
RETURNING id, facility_id;同じ述語をアプリケーションコード側にもミラーし、データベースなしでユニットテストできるようにします。
export type PaymentState = 'unpaid' | 'authorized' | 'captured' | 'refunded';
export interface SweepCandidate {
readonly status: StayStatus;
readonly holdExpiresAt: Instant;
readonly paymentState: PaymentState;
readonly checkedInAt: Instant | null;
}
export function isSweepable(c: SweepCandidate, now: Instant): boolean {
if (c.status !== 'reserved') return false;
if (c.checkedInAt !== null) return false;
if (c.paymentState !== 'unpaid') return false;
return c.holdExpiresAt < now;
}そして、ハッピーパスではなく不変条件を、ポリシー文書のように読めるテストで固定します。
describe('TTL sweep invariants', () => {
const states: PaymentState[] = ['authorized', 'captured', 'refunded'];
it.each(states)('never cancels a %s hold', (paymentState) => {
expect(isSweepable(candidate({ paymentState }), NOW)).toBe(false);
});
it('never cancels a stay that already checked in', () => {
expect(isSweepable(candidate({ checkedInAt: NOW }), NOW)).toBe(false);
});
});関連する洞察として、KPI のカウンタはライフサイクルのステータスから独立していなければなりません。 「課金対象のチェックイン数」を、現在のステータスが checked_in である行を数えて算出していると、その後のステータス遷移が黙って履歴を書き換えてしまいます。現在の行の状態 ではなく イベント を数えましょう。
// ❌ history mutates when status changes later
const billable = reservations.filter((r) => r.status === 'checked_in').length;
// ✅ an immutable event is counted exactly once, forever
const billable = events.filter((e) => e.type === 'checkin.completed').length;これはコードレビューの慣習ではなく CI のガードにする価値があります。集計クエリが可変のステータスカラムを参照していたらビルドを失敗させるテストです。
被害の修復: 冪等かつドライラン優先のバックフィル
不正なスイーパーがすでにイベントを書き込んでしまっている場合、修復スクリプト自体がセキュリティ上センシティブなツールになります。譲れない 3 点は次のとおりです。
interface BackfillOptions {
readonly dryRun: boolean; // default true
readonly limit: number; // bounded blast radius per run
readonly reasonFilter: string; // only rows written by the known-bad writer
}
export async function repairSpuriousCancels(opts: BackfillOptions) {
const rows = await findCancelledBy(opts.reasonFilter, opts.limit);
const targets = rows.filter((r) => r.paymentState !== 'unpaid' || r.checkedInAt !== null);
logger.info('backfill.plan', { scanned: rows.length, targets: targets.length, dryRun: opts.dryRun });
if (opts.dryRun) return { planned: targets.length, applied: 0 };
const applied = await restoreAll(targets, { auditReason: 'backfill:ttl_sweep_repair' });
return { planned: targets.length, applied };
}デフォルトはドライラン、バッチには上限を設け、すべての書き込みには どのスクリプト が変更したかを特定できる監査理由を付けます。「誰がこの行を書き、なぜそうしたのか」を監査ログだけで答えられないなら、そのログは飾りにすぎません。
4. 認可: 評価順序こそがポリシー
このコードベースのフィーチャーフラグは、メニュー、API、運用ツールをゲートしています。つまりフラグの評価器は認可の一部なのです。ここで起きた 2 つのバグは示唆に富んでいます。
バグ A: 重複排除の処理が管理者バイパスより前に走っていた。 評価器はまず重複したスコープオーバーライドを畳み込んでいましたが、その畳み込みがたまたま管理者アクセスを付与するレコードを落としてしまいました。アクセス制御が、偶発的な処理順序によって決定されていたのです。
バグ B: 重複したスコープオーバーライドが黙って機能を無効化した。 2 つの行が同じスコープを異なる値で指していた場合、reducer は配列順で「最後が勝つ」を採用していました。これはデータベースの並び順、つまり任意の順序です。無関係な INSERT のあとで機能がオフに反転し得ました。
修正は、解決ルールを明示的かつ全域にすることです。
export type Scope = 'global' | 'environment' | 'facility' | 'user';
const PRECEDENCE: Record<Scope, number> = { global: 0, environment: 1, facility: 2, user: 3 };
export interface Override {
readonly scope: Scope;
readonly enabled: boolean;
readonly updatedAt: Instant;
readonly id: string;
}
export interface Decision {
readonly enabled: boolean;
readonly reason: 'admin_bypass' | 'override' | 'default';
readonly source?: string;
}
export function evaluateFlag(
overrides: readonly Override[],
ctx: { isAdminBypass: boolean; defaultEnabled: boolean },
): Decision {
// 1. Bypass is evaluated FIRST and cannot be shadowed by later passes.
if (ctx.isAdminBypass) return { enabled: true, reason: 'admin_bypass' };
if (overrides.length === 0) {
return { enabled: ctx.defaultEnabled, reason: 'default' };
}
// 2. Deterministic winner: narrowest scope, then newest, then stable id.
const winner = [...overrides].sort((a, b) => {
const byScope = PRECEDENCE[b.scope] - PRECEDENCE[a.scope];
if (byScope !== 0) return byScope;
const byTime = b.updatedAt - a.updatedAt;
return byTime !== 0 ? byTime : a.id.localeCompare(b.id);
})[0];
return { enabled: winner.enabled, reason: 'override', source: winner.id };
}真似する価値のある性質が 3 つあります。
- 順序は創発するのではなく宣言される。
PRECEDENCEはレビュー可能なテーブルですが、配列順はそうではありません。 - すべての決定が理由を返す。
{ enabled: false, reason: 'override', source: 'ovr_123' }は、サポートチケットを 1 クエリの調査に変えます。裸の boolean は監査不能です。 - 競合が観測可能である。 2 つのオーバーライドが同じスコープを共有しているときは、黙って片方を選ぶのではなく、両方の id を含む警告を出します。
const sameScope = overrides.filter((o) => o.scope === winner.scope);
if (sameScope.length > 1) {
logger.warn('flag.conflicting_overrides', {
scope: winner.scope,
ids: sameScope.map((o) => o.id),
chosen: winner.id,
});
}5. 認証ステート: 何も変わっていないならオブジェクトの同一性を保つ
認証 SDK はイベントを気前よく発行します。token のリフレッシュ、タブのフォーカス、ストレージの同期などです。それらのイベントの多くは、すでに保持しているものと 値として等しい セッションを運んできます。それでもストアがオブジェクトを置き換えてしまうと、すべての購読者が再レンダリングし、session をキーにした effect が再実行され、リフェッチの嵐、認可呼び出しの重複、ときにはデバイスゲートウェイに対する再接続ループが発生します。
修正は、意味のある変更がないときには前の参照を返す reducer です。
export interface Session {
readonly userId: string;
readonly accessToken: string;
readonly expiresAt: Instant;
}
function isSameSession(a: Session | null, b: Session | null): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
return (
a.userId === b.userId &&
a.accessToken === b.accessToken &&
a.expiresAt === b.expiresAt
);
}
export function sessionReducer(prev: Session | null, next: Session | null): Session | null {
// Returning `prev` keeps referential identity stable for subscribers.
return isSameSession(prev, next) ? prev : next;
}同種のバグはデバイス接続の経路でも現れました。リフレッシュの競合により、no-op のステート更新が実際の変更として扱われ、誤った「再接続」遷移が発生したのです。ステートコンテナは通知ではなく遷移を表すべきです。 値が変わっていないなら、遷移は起きていません。
6. 真っ白にせず、回復する
継続的にデプロイされる SPA には避けられない故障モードがあります。クライアントが古い HTML シェルを保持しており、それがすでに存在しないルートチャンクを参照しているケースです。動的インポートは reject され、エラーバウンダリは有用な情報を何も掴めず、オペレーターは白い画面を目にします。しかも扉の前に立ちながら。
白い画面は最悪の結末です。特定の失敗を検出し、ループガード付きで自己修復しましょう。
const RELOAD_KEY = 'app:chunk-reload-at';
const RELOAD_WINDOW_MS = 30_000;
function isStaleChunkError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /Loading (CSS )?chunk .* failed|Failed to fetch dynamically imported module|Importing a module script failed/i.test(message);
}
export function recoverFromStaleChunk(error: unknown): boolean {
if (!isStaleChunkError(error)) return false;
const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0);
if (Date.now() - last < RELOAD_WINDOW_MS) {
// Already tried recently — a reload loop is worse than an error screen.
return false;
}
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
window.location.reload();
return true;
}同じ原則は決済でも現れました。上流の顧客レコードが帯域外で削除されていたとき、SDK は生の 502 をそのまま表面化させていました。正しい振る舞いは、上流のエラーを分類し、欠けたリソースを再プロビジョニングし、ローカライズされた実行可能なメッセージを返すことです。
export type SetupFailure =
| { kind: 'stale_customer'; customerId: string }
| { kind: 'card_declined'; declineCode: string }
| { kind: 'upstream_unavailable'; retryable: true };
export async function ensurePaymentSetup(userId: string): Promise<Result<SetupIntent, SetupFailure>> {
const customerId = await getStoredCustomerId(userId);
const customer = customerId ? await fetchCustomer(customerId) : null;
if (customerId && (customer === null || customer.deleted)) {
logger.warn('payment.stale_customer', { userId, customerId });
const fresh = await createCustomer(userId);
await replaceStoredCustomerId(userId, fresh.id);
return ok(await createSetupIntent(fresh.id));
}
return ok(await createSetupIntent(customer?.id ?? (await createCustomer(userId)).id));
}また非同期連携においては、dead-letter queue は設計の半分にすぎません。残る半分は、「化石」化したエントリを冪等に再生し、失敗した試行が壊したカウンタを整合させられるリカバリワーカーです。
export async function recoverDeadLetters(batch: DeadLetter[]): Promise<RecoveryReport> {
const report: RecoveryReport = { replayed: 0, dropped: 0, failed: 0 };
for (const item of batch) {
if (await isAlreadyApplied(item.idempotencyKey)) {
await dropDeadLetter(item.id, 'already_applied');
report.dropped += 1;
continue;
}
try {
await applyWithIdempotency(item.payload, item.idempotencyKey);
await reconcileCounters(item.aggregateId);
report.replayed += 1;
} catch (error) {
logger.error('dlq.replay_failed', { id: item.id, error });
report.failed += 1;
}
}
return report;
}すべての再生経路は key によって冪等であり、派生カウンタを整合させ、何を行ったかを報告しなければなりません。そうでなければ、リカバリツールは第二のインシデントになります。
7. 危険な編集は「非推奨」ではなく「不可能」にする
適用済みのデータベースマイグレーションは、契約上 append-only です。本番ですでに実行されたものを編集するということは、環境が黙って乖離するということです。コードレビューはたいていこれを捕まえますが、「たいてい」はアクセス制御モデルではありません。
書き込み前のツールフックは、この慣習を強制されたガードに変えます。
#!/usr/bin/env bash
set -euo pipefail
file="$1"
case "$file" in
*/migrations/*.sql) ;;
*) exit 0 ;;
esac
version="$(basename "$file" | cut -d_ -f1)"
if grep -qx "$version" .migrations-applied; then
echo "BLOCKED: migration $version is already applied. Add a new migration instead." >&2
exit 1
fi
exit 0同じ哲学が API の露出面にも当てはまります。どの edge function が公開到達可能かを明示的なレジストリで管理し、それ以外はすべて既定で内部扱いにします。誰も private として列挙しなかったから 公開されているエンドポイントは、スキャナーを待つ脆弱性です。
{
"public": ["guest-inbox-timeline", "checkin-host-session"],
"internal": ["auto-checkout-sweep", "gcal-dlq-recover", "billing-reconcile"],
"policy": "deny-by-default: functions absent from this registry fail CI"
}8. 型付きの失敗は汎用エラーに勝る
予約が拒否されたとき、「conflict」という情報だけでは呼び出し側が正しい行動を取るには足りません。既存の滞在との完全な重なりは本物の拒否ですが、清掃バッファにだけ触れる衝突は 15 分ずらせば解決できるかもしれません。この区別を型システムでモデル化しましょう。
export type BookingConflict =
| { kind: 'overlap'; conflictingId: string }
| { kind: 'buffer_only'; conflictingId: string; bufferMinutes: number; suggestedStart: Instant }
| { kind: 'quota_exceeded'; limit: number; used: number };
export function describeConflict(conflict: BookingConflict): string {
switch (conflict.kind) {
case 'overlap':
return 'errors.booking.overlap';
case 'buffer_only':
return 'errors.booking.buffer_only';
case 'quota_exceeded':
return 'errors.booking.quota_exceeded';
default: {
const never: never = conflict;
throw new Error(`Unhandled conflict: ${JSON.stringify(never)}`);
}
}
}要点は never の分岐です。新しい conflict の種類が追加されたとき、コンパイラがそれを処理すべきすべての箇所を見つけてくれます。またこの関数がハードコードされた文字列ではなく メッセージキー を返している点にも注目してください。オペレーター向けのテキストをハードコードすることは正しさの問題です。サポート担当者が読むメッセージは、ゲストが見たものと一致していなければならないからです。
バリデーションも同じ境界に属します。アイデンティティのレコードになる自由記述の受付回答は、信頼するのではなくパースすべきです。
const IntakeAnswer = z.object({
email: z.string().trim().toLowerCase().email(),
fields: z.record(z.string().min(1)),
});
export function parseIntake(input: unknown) {
const result = IntakeAnswer.safeParse(input);
if (!result.success) {
return err({ kind: 'invalid_intake', issues: result.error.issues });
}
return ok(result.data);
}まとめ
このイテレーションを貫いていたのは、単一の機能ではありませんでした。それは一連の習慣です。
- 境界で正規化する。 文字列ではなく instant を比較し、入力は信頼せずパースする。
- ライフサイクル状態は 1 つの全域関数で導出する。 優先順位を書き下し、テーブルテストで固定する。
- バックグラウンドの書き込みは安全性述語で守る。 SQL とテスト可能なコードの両方にミラーし、ポリシー文のように読める不変条件テストで固定する。
- 可変のステータスカラムではなく不変のイベントを数える。 履歴が遡って変わってはいけない。
- 認可の順序を明示し、すべての決定を説明可能にする(
reason、source)。競合は恣意的に解決せずログに残す。 - 何も変わっていないならオブジェクトの同一性を保つ。 ステートコンテナは通知ではなく遷移を表すべきである。
- 真っ白にせず回復する。 ループガード、冪等な再生、カウンタの整合を伴って。
- 危険な編集のルールは機械的に強制する。 不変のマイグレーション、deny-by-default の API レジストリなど、レビューの規律に頼らない。
どれも華やかではありません。しかしこれらが揃うことが、「たいてい動くシステム」と「扉に取り付けてもよいと思えるシステム」との違いを生みます。