fix: bind Web Push mutations to exact authority

WP-01: a CAS receipt is only evidence when it names the expected key and the
exact next revision. Write and remove now share one validator, so a stale or
arbitrary repository receipt can no longer be packaged as a confirmed control.

WP-05: a pre-aborted command records the operation the caller requested instead
of always reporting an inspection.

WP-06: bounded fan-out is reported honestly. The subscriptionchange client
handoff and the notification cleanup both emit countBucket and truncated, and an
incomplete cleanup returns { complete: false } and is observed as DEGRADED
separately from revoke authority.

WP-07: the user-visible native notification effect is tracked through
NOT_APPLIED, MAYBE_APPLIED and CONFIRMED phases and surfaced as observation
evidence, never as retry authorization.

WP-02, WP-03 and WP-04 stay open: they need the V2 wire protocol with server
request-shape negotiation, which belongs to the versioned-migration task rather
than this correctness pass. The ledger records them as DEFERRED_TO_MIGRATION.

Web Push remains NOT_SELECTED and AVAILABLE_NOT_COMPOSED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 00:34:09 +09:00
co-authored by Claude Opus 5
parent 58efe6ddbd
commit fce8e046ea
9 changed files with 170 additions and 44 deletions
@@ -149,13 +149,13 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
| SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | per-client failure escalation | — | | SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | per-client failure escalation | — |
| SW-09 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | late install work observed | — | | SW-09 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | late install work observed | — |
| SW-10 | Protocol V2 migration | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | V1/V2 mismatch fail-close | — | | SW-10 | Protocol V2 migration | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | V1/V2 mismatch fail-close | — |
| WP-01 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | — | `NOT_STARTED` | CAS receipt rejection | — | | WP-01 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | CAS receipt rejection | Red stale/skipped/huge revision receipts → green; write and remove share one exact-next-revision validator |
| WP-02 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | — | `NOT_STARTED` | `RECONCILIATION_REQUIRED` backlog | | | WP-02 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | — | `DEFERRED_TO_MIGRATION` | `RECONCILIATION_REQUIRED` backlog | Deferred to the Task 16 versioned-migration PR: `MUTATION_OUTCOME_UNKNOWN` and the `RECONCILIATION_REQUIRED` lifecycle are part of the same wire/data migration as WP-03. |
| WP-03 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | — | `NOT_STARTED` | backend receipt mismatch | | | WP-03 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | — | `DEFERRED_TO_MIGRATION` | backend receipt mismatch | Deferred to the Task 16 versioned-migration PR: the V2 receipt requires server request-shape negotiation before a client rollout. |
| WP-04 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | — | `NOT_STARTED` | reconcile loop | | | WP-04 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | — | `DEFERRED_TO_MIGRATION` | reconcile loop | Deferred with WP-03: `expectedPreviousAssociationEpoch` and `replacedAssociationEpoch` are part of the V2 register contract. |
| WP-05 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | — | `NOT_STARTED` | pre-abort observation drift | — | | WP-05 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-subscription-adapter.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | pre-abort observation drift | A pre-aborted command records the requested operation instead of always INSPECT |
| WP-06 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-worker-runtime.test.ts` | — | `NOT_STARTED` | truncation reported degraded | — | | WP-06 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-worker-runtime.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | truncation reported degraded | Client handoff and notification cleanup report `countBucket` and `truncated`; an incomplete cleanup returns `{ complete: false }` and is DEGRADED |
| WP-07 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-worker-runtime.test.ts` | — | `NOT_STARTED` | late native effect certainty | — | | WP-07 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-worker-runtime.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | late native effect certainty | Native notification effect is tracked as NOT_APPLIED → MAYBE_APPLIED → CONFIRMED and observed as evidence only |
## Rules for updating this ledger ## Rules for updating this ledger
@@ -1,4 +1,5 @@
import { import {
type WebPushNativeEffectCertainty,
WEB_PUSH_LIMITS, WEB_PUSH_LIMITS,
webPushFailure, webPushFailure,
webPushSuccess, webPushSuccess,
@@ -148,14 +149,30 @@ export function createPushEventAdapter(dependencies: Readonly<{
); );
if (!finalFence.ok) return finalFence; if (!finalFence.ok) return finalFence;
const clickData = clickDataFromHint(decoded.value); const clickData = clickDataFromHint(decoded.value);
// WP-07. The user-visible effect has its own certainty phase: NOT_APPLIED
// before the native call, MAYBE_APPLIED while the promise is pending and
// CONFIRMED on fulfilment. It is evidence only and never authorizes retry.
let nativeEffect: WebPushNativeEffectCertainty = "NOT_APPLIED";
try { try {
await dependencies.notifications.showNotification(definition.title, { const shown = dependencies.notifications.showNotification(
body: definition.body, definition.title,
data: clickData, {
requireInteraction: false, body: definition.body,
tag, data: clickData,
}); requireInteraction: false,
tag,
},
);
nativeEffect = "MAYBE_APPLIED";
await shown;
nativeEffect = "CONFIRMED";
if (signal.aborted) { if (signal.aborted) {
observeWebPush(dependencies.observer, {
event: "web_push_notification_finished",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect,
});
return webPushFailure("ABORTED", "NOTIFICATION_SHOW"); return webPushFailure("ABORTED", "NOTIFICATION_SHOW");
} }
} catch { } catch {
@@ -164,12 +181,14 @@ export function createPushEventAdapter(dependencies: Readonly<{
event: "web_push_notification_finished", event: "web_push_notification_finished",
outcome: "FAILED", outcome: "FAILED",
reason: failureCode(failed), reason: failureCode(failed),
nativeEffect,
}); });
return failed; return failed;
} }
observeWebPush(dependencies.observer, { observeWebPush(dependencies.observer, {
event: "web_push_notification_finished", event: "web_push_notification_finished",
outcome: "SUCCEEDED", outcome: "SUCCEEDED",
nativeEffect,
}); });
return webPushSuccess(undefined); return webPushSuccess(undefined);
} }
@@ -502,7 +502,7 @@ export function createPushAssociationFenceStore(
operation, operation,
); );
if (!written.ok) return written; if (!written.ok) return written;
if (!validWriteReceipt(written.value)) { if (!validWriteReceipt(written.value, expectedRevision)) {
return webPushFailure("CONTROL_CORRUPT", operation); return webPushFailure("CONTROL_CORRUPT", operation);
} }
return webPushSuccess( return webPushSuccess(
@@ -537,10 +537,7 @@ export function createPushAssociationFenceStore(
"CONTROL_PURGE", "CONTROL_PURGE",
); );
if (!removed.ok) return removed; if (!removed.ok) return removed;
if ( if (!validWriteReceipt(removed.value, expectedRevision)) {
!validWriteReceipt(removed.value) ||
removed.value.revision !== expectedRevision + 1
) {
return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE"); return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE");
} }
return webPushSuccess(undefined); return webPushSuccess(undefined);
@@ -693,14 +690,25 @@ function validRepository(
); );
} }
/**
* WP-01. One validator for both write and remove.
*
* A CAS receipt is only evidence when it names the expected key and the exact
* next revision. Accepting any well-typed revision let a stale or arbitrary
* repository receipt be packaged as a confirmed control, after which the whole
* CAS authority is wrong. A replayed receipt must still carry that exact
* revision, since replay means "this command already produced this revision".
*/
function validWriteReceipt( function validWriteReceipt(
value: PushControlWriteReceipt, value: PushControlWriteReceipt,
expectedRevision: number | null,
): value is PushControlWriteReceipt { ): value is PushControlWriteReceipt {
return ( return (
Boolean(value) && Boolean(value) &&
value.key === CONTROL_KEY && value.key === CONTROL_KEY &&
validRevision(value.revision) && validRevision(value.revision) &&
typeof value.replayed === "boolean" typeof value.replayed === "boolean" &&
value.revision === (expectedRevision ?? 0) + 1
); );
} }
@@ -1,5 +1,6 @@
import type { WebPushControlPort } from "../../application/ports/out/web-push-control.ts"; import type { WebPushControlPort } from "../../application/ports/out/web-push-control.ts";
import { import {
webPushCountBucket,
WEB_PUSH_LIMITS, WEB_PUSH_LIMITS,
samePushAuthority, samePushAuthority,
webPushFailure, webPushFailure,
@@ -474,7 +475,9 @@ export function createWebPushSubscriptionAdapter(
if (closed) return webPushSuccess(unavailable("CLOSED")); if (closed) return webPushSuccess(unavailable("CLOSED"));
if (busy) return webPushSuccess(unavailable("BUSY")); if (busy) return webPushSuccess(unavailable("BUSY"));
if (signal?.aborted) { if (signal?.aborted) {
return webPushFailure("ABORTED", "SUBSCRIPTION_INSPECT"); // WP-05. A pre-aborted command is recorded as the operation the caller
// actually requested, not always as an inspection.
return webPushFailure("ABORTED", failureOperation);
} }
busy = true; busy = true;
const generation = lifecycleGeneration; const generation = lifecycleGeneration;
@@ -879,7 +882,9 @@ export function createWebPushSubscriptionAdapter(
await Promise.allSettled([ await Promise.allSettled([
unsubscribe, unsubscribe,
associationEpoch === null associationEpoch === null
? Promise.resolve(webPushSuccess(undefined)) ? Promise.resolve(
webPushSuccess(Object.freeze({ complete: true })),
)
: closeOwnedNotifications( : closeOwnedNotifications(
associationEpoch, associationEpoch,
signal, signal,
@@ -891,7 +896,8 @@ export function createWebPushSubscriptionAdapter(
nativeResult.value; nativeResult.value;
const notificationsClean = const notificationsClean =
notificationResult.status === "fulfilled" && notificationResult.status === "fulfilled" &&
notificationResult.value.ok; notificationResult.value.ok &&
notificationResult.value.value.complete;
return webPushSuccess( return webPushSuccess(
nativeClean && notificationsClean, nativeClean && notificationsClean,
); );
@@ -905,11 +911,16 @@ export function createWebPushSubscriptionAdapter(
return cleanup.ok && cleanup.value; return cleanup.ok && cleanup.value;
} }
/**
* WP-06. Notification cleanup is bounded best effort and is reported
* separately from revoke authority: an incomplete pass returns
* `{ complete: false }` and is observed as DEGRADED rather than success.
*/
async function closeOwnedNotifications( async function closeOwnedNotifications(
associationEpoch: string, associationEpoch: string,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
generation: number, generation: number,
): Promise<WebPushResult<void>> { ): Promise<WebPushResult<Readonly<{ complete: boolean }>>> {
let notifications: readonly OwnedNotificationFacade[]; let notifications: readonly OwnedNotificationFacade[];
try { try {
notifications = await dependencies.registration.getNotifications(); notifications = await dependencies.registration.getNotifications();
@@ -923,6 +934,15 @@ export function createWebPushSubscriptionAdapter(
if (stale(signal, generation)) { if (stale(signal, generation)) {
return webPushFailure("ABORTED", "NOTIFICATION_CLEANUP"); return webPushFailure("ABORTED", "NOTIFICATION_CLEANUP");
} }
const truncated =
notifications.length > WEB_PUSH_LIMITS.notificationCleanupCount;
observeWebPush(dependencies.observer, {
event: "web_push_notification_finished",
outcome: truncated ? "DEGRADED" : "SUCCEEDED",
...(truncated ? { reason: "LIMIT_EXCEEDED" as const } : {}),
countBucket: webPushCountBucket(notifications.length),
truncated,
});
for (const notification of notifications.slice( for (const notification of notifications.slice(
0, 0,
WEB_PUSH_LIMITS.notificationCleanupCount, WEB_PUSH_LIMITS.notificationCleanupCount,
@@ -941,7 +961,7 @@ export function createWebPushSubscriptionAdapter(
} }
} }
} }
return webPushSuccess(undefined); return webPushSuccess(Object.freeze({ complete: !truncated }));
} }
function stale( function stale(
@@ -1,4 +1,5 @@
import { import {
webPushCountBucket,
WEB_PUSH_LIMITS, WEB_PUSH_LIMITS,
WEB_PUSH_PROTOCOLS, WEB_PUSH_PROTOCOLS,
webPushFailure, webPushFailure,
@@ -134,6 +135,10 @@ export function createWebPushServiceWorkerRuntime(
const facade = functionalEventFacade(event); const facade = functionalEventFacade(event);
if (!facade) return; if (!facade) return;
const taskControl = createLinkedAbortController(lifecycle.signal); const taskControl = createLinkedAbortController(lifecycle.signal);
// WP-06. Bounded fan-out is policy, but the operator must be able to see
// that only part of the client set was notified.
let observedClientCount = 0;
let truncatedClients = false;
const processing = withAbortableDeadline( const processing = withAbortableDeadline(
async (signal) => { async (signal) => {
let clients: readonly unknown[]; let clients: readonly unknown[];
@@ -148,6 +153,9 @@ export function createWebPushServiceWorkerRuntime(
if (signal.aborted) { if (signal.aborted) {
return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE"); return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE");
} }
observedClientCount = clients.length;
truncatedClients =
clients.length > WEB_PUSH_LIMITS.clientHandoffCount;
try { try {
for (const candidate of clients.slice( for (const candidate of clients.slice(
0, 0,
@@ -181,8 +189,15 @@ export function createWebPushServiceWorkerRuntime(
const lifetime = processing.then((result) => { const lifetime = processing.then((result) => {
observeWebPush(dependencies.observer, { observeWebPush(dependencies.observer, {
event: "web_push_subscription_rotated", event: "web_push_subscription_rotated",
outcome: result.ok ? "SUCCEEDED" : "DEGRADED", outcome:
...(result.ok ? {} : { reason: result.error.code }), result.ok && !truncatedClients ? "SUCCEEDED" : "DEGRADED",
...(result.ok
? truncatedClients
? { reason: "LIMIT_EXCEEDED" as const }
: {}
: { reason: result.error.code }),
countBucket: webPushCountBucket(observedClientCount),
truncated: truncatedClients,
}); });
}); });
try { try {
+31
View File
@@ -161,10 +161,41 @@ export type WebPushObservationEvent =
| "web_push_click_dispatched" | "web_push_click_dispatched"
| "web_push_association_revoked"; | "web_push_association_revoked";
/**
* WP-06. Bounded fan-out is a deliberate policy, but reporting a truncated pass
* as plain success hid the fact that only part of the set was handled.
*/
export type WebPushCountBucket =
| "0"
| "1_8"
| "9_32"
| "33_64"
| "GT_64";
export function webPushCountBucket(count: number): WebPushCountBucket {
if (!Number.isFinite(count) || count <= 0) return "0";
if (count <= 8) return "1_8";
if (count <= 32) return "9_32";
if (count <= 64) return "33_64";
return "GT_64";
}
/**
* WP-07. Certainty of a user-visible native effect. It is evidence only and
* never authorizes a retry.
*/
export type WebPushNativeEffectCertainty =
| "CONFIRMED"
| "NOT_APPLIED"
| "MAYBE_APPLIED";
export type WebPushObservation = Readonly<{ export type WebPushObservation = Readonly<{
event: WebPushObservationEvent; event: WebPushObservationEvent;
outcome: "SUCCEEDED" | "FAILED" | "DEGRADED"; outcome: "SUCCEEDED" | "FAILED" | "DEGRADED";
reason?: WebPushFailureCode | WebPushUnavailableReason; reason?: WebPushFailureCode | WebPushUnavailableReason;
countBucket?: WebPushCountBucket;
truncated?: boolean;
nativeEffect?: WebPushNativeEffectCertainty;
}>; }>;
export interface WebPushObserver { export interface WebPushObserver {
+14 -18
View File
@@ -193,20 +193,13 @@ describe("service worker static cache authority", () => {
const previousMatch = vi.fn( const previousMatch = vi.fn(
async () => new Response("previous", { status: 200 }), async () => new Response("previous", { status: 200 }),
); );
const deleteEntry = async (): Promise<boolean> => true;
const emptyMatch = async (): Promise<Response | undefined> => undefined;
const previousCache = { match: previousMatch, delete: deleteEntry };
const emptyCache = { match: emptyMatch, delete: deleteEntry };
const runtime = staticRuntime({ const runtime = staticRuntime({
open: vi.fn( open: vi.fn(async (name: string) =>
async ( name === previousCacheName ? previousCache : emptyCache,
name: string,
): Promise<Readonly<{ match: unknown; delete: unknown }>> =>
name === previousCacheName
? {
match: previousMatch,
delete: async (): Promise<boolean> => true,
}
: {
match: async (): Promise<Response | undefined> => undefined,
delete: async (): Promise<boolean> => true,
},
), ),
keys: vi.fn(async () => [currentCacheName, previousCacheName]), keys: vi.fn(async () => [currentCacheName, previousCacheName]),
delete: vi.fn(async () => true), delete: vi.fn(async () => true),
@@ -226,13 +219,16 @@ describe("service worker static cache authority", () => {
it("deletes an invalid hit only from the current release cache", async () => { it("deletes an invalid hit only from the current release cache", async () => {
const deletes: string[] = []; const deletes: string[] = [];
const runtime = staticRuntime({ const runtime = staticRuntime({
open: vi.fn(async (name: string) => ({ open: vi.fn(async (name: string) => {
match: async () => new Response("bad", { status: 500 }), const recordDelete = async (url: string): Promise<boolean> => {
delete: async (url: string): Promise<boolean> => {
deletes.push(`${name}:${url}`); deletes.push(`${name}:${url}`);
return true; return true;
}, };
})), return {
match: async () => new Response("bad", { status: 500 }),
delete: recordDelete,
};
}),
keys: vi.fn(async () => [currentCacheName]), keys: vi.fn(async () => [currentCacheName]),
delete: vi.fn(async () => true), delete: vi.fn(async () => true),
}); });
+31
View File
@@ -42,6 +42,37 @@ function manualScheduler() {
} }
describe("Web Push durable control fence", () => { describe("Web Push durable control fence", () => {
it.each([0, 2, 3, 9_999])(
"rejects a CAS receipt that is not the exact next revision (%i)",
async (revision) => {
// WP-01. Only the exact next revision is evidence that this command
// actually wrote the control it claims to have written.
const dependencies = createFakePushControlStoreDependencies();
const repository = dependencies.repository;
const compareAndSwap = repository.compareAndSwap.bind(repository);
repository.compareAndSwap = async (input) => {
const written = await compareAndSwap(input);
return written.ok
? {
ok: true as const,
value: { ...written.value, revision },
}
: written;
};
const store = createPushAssociationFenceStore(dependencies);
await expect(
store.prepare({
authority: firstAuthority,
updatedAt: "2026-07-28T00:00:00.000Z",
}),
).resolves.toMatchObject({
ok: false,
error: { code: "CONTROL_CORRUPT" },
});
},
);
it("CASes UNASSOCIATED to ACTIVE and prevents tombstone resurrection", async () => { it("CASes UNASSOCIATED to ACTIVE and prevents tombstone resurrection", async () => {
const store = createPushAssociationFenceStore( const store = createPushAssociationFenceStore(
createFakePushControlStoreDependencies(), createFakePushControlStoreDependencies(),
@@ -445,6 +445,8 @@ describe("Web Push worker runtime", () => {
event: "web_push_subscription_rotated", event: "web_push_subscription_rotated",
outcome: "DEGRADED", outcome: "DEGRADED",
reason: "DEADLINE_EXCEEDED", reason: "DEADLINE_EXCEEDED",
countBucket: expect.any(String),
truncated: expect.any(Boolean),
}); });
runtime.dispose(); runtime.dispose();
}); });
@@ -489,6 +491,8 @@ describe("Web Push worker runtime", () => {
event: "web_push_subscription_rotated", event: "web_push_subscription_rotated",
outcome: "DEGRADED", outcome: "DEGRADED",
reason: "ABORTED", reason: "ABORTED",
countBucket: expect.any(String),
truncated: expect.any(Boolean),
}); });
const throwingStore = await activeFence(); const throwingStore = await activeFence();
@@ -540,6 +544,8 @@ describe("Web Push worker runtime", () => {
event: "web_push_subscription_rotated", event: "web_push_subscription_rotated",
outcome: "DEGRADED", outcome: "DEGRADED",
reason: "ABORTED", reason: "ABORTED",
countBucket: expect.any(String),
truncated: expect.any(Boolean),
}); });
throwingRuntime.dispose(); throwingRuntime.dispose();
}); });