feat: web rotation UX and durable anti-rollback client state#587
Conversation
Entire-Checkpoint: 5d783d7577ae
Entire-Checkpoint: 2ebaa765350e
Entire-Checkpoint: 413c22b4ce10
Entire-Checkpoint: e35021f660ee
Entire-Checkpoint: 1ced91067630
Entire-Checkpoint: 3b7c7f468480
… SDK, web-e2e coverage) Entire-Checkpoint: 50ef03eff3dd
Entire-Checkpoint: fac201320be2
…machine - monotonic-max generation and seq floors over an injected HighWaterStore - seedFromGrant owner-vouched first-contact seed (only raises, never lowers) - restart-persistence: a fresh instance over the same backing store observes prior floors - malformed stored values (negative, fractional, NaN, non-numeric) treated as absent
- createRotationHighWater exposes monotonic-max generation and seq floors over an injected HighWaterStore seam, no in-instance cache - seedFromGrant raises the generation floor only when higher (owner-vouched) - V5 fail-closed validation: malformed stored values treated as absent
- below-floor seq/generation throws distinguishable regression errors, no bump - non-regressing values bump both floors and resolve normally - cold-device first-contact versionFloor gate (below rejects, at-or-above seeds) - GenerationRegressionError and SequenceRegressionError are instanceof-distinguishable
- Add UpdateGrantDto with hex-validated readDescriptorRef and numeric-string rootGeneration - Add PATCH :shareId/grant controller route mirroring updateShareItemName idiom - Add SharesService.updateGrant with sharerId ownership check, 403 for non-owner, 404 for unknown share
- Extend Notification type with action?: label onClick field - Render action button in NotificationToast between message and x dismiss - Skip auto-dismiss for terminal error toasts carrying an action - Add notification-action-btn CSS for muted default / green hover styling
…port - GenerationRegressionError and SequenceRegressionError, instanceof-distinguishable - enforceResolved rejects seq/generation regressions, applies cold-device versionFloor gate on first contact, bumps monotonic-max otherwise - pure pass/throw pre-unseal gate, never emits an AAD/unseal parameter - exported from the SDK barrel for the web IndexedDB adapter (68-06)
- Regenerate openapi.json, generated shares client, and updateGrantDto model via pnpm api:generate
- Add useRotationStore with idle/root-cut/tail-walk/resuming status - Presentation-only; drivers wired in 68-08 call the setters
…achine - documents the RED->GREEN TDD cycle for createRotationHighWater and enforceResolved - records coverage mapping for ROT-07 deliverables
- Add RotationStatusBadge non-interactive status pill component - Mount badge in header-right before UserMenu - Add rotation-status-badge styles reusing header-search-btn tokens
Add SUMMARY.md documenting the notification action affordance and rotation status badge primitives built for the 68-UI-SPEC contract.
Rewire the stub fetch functions in share.service.ts to call the generated sharesControllerGetReceivedShares/sharesControllerGetSentShares endpoints, and extend the ReceivedShare/SentShare store types with readDescriptorRef, rootGeneration (parsed to number with an isFinite fail-closed guard), and rootNodeId. This is the D-07 data path ROT-07s durable generation floor seeds from.
Remove the useAuth.ts shareCallbacks config (getCoveringShares/addShareKeys) and no-op the useSharedNavigation.ts addShareKeysFn seed callback now that the web addShareKeys fan-out function is deleted (SC#2 / D-12). Both call sites always threw the Phase-68-deferred stub, so this changes no observed behavior while retiring the legacy per-mutation key-wrap wiring. The SDK's own ShareCallbacks/SharedFolderState.addShareKeysFn seams in packages/sdk are untouched.
Release Preview
Cascade Details
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #587 +/- ##
===========================================
+ Coverage 65.64% 83.79% +18.14%
===========================================
Files 151 117 -34
Lines 12351 8498 -3853
Branches 1390 1457 +67
===========================================
- Hits 8108 7121 -987
+ Misses 4009 1139 -2870
- Partials 234 238 +4
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Greptile SummaryThis phase wires the SDK's scope-exit rotation (
Confidence Score: 4/5Safe to merge with one structural UX gap in the rotation-mutation coupling worth addressing before users with active grants hit transient rotation failures The anti-rollback enforcement, IDB adapter, reconcile-before-publish, and owner-reconcile transport are all well-structured. The main concern is that scope-exit rotation errors propagate to the calling hook after folder:updated has already been emitted — a rotation-side IPNS failure causes the hook to surface an error for a mutation that already landed on the network and is visible in the UI, creating a conflicting signal for the user. packages/sdk/src/client.ts — the ordering of folder:updated emission vs. performScopeExitRotation in renameItem, deleteItem, and deleteToBin Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Hook as useFolderMutations
participant UX as runWithFailureUx
participant SDK as CipherBoxClient
participant HW as RotationHighWater IDB
participant IPNS as IPNS/API
participant Store as FolderStore + UI
Hook->>UX: "runWithFailureUx(() => renameItem(...))"
UX->>SDK: renameItem(...)
SDK->>IPNS: resolveIpnsRecord reconcile check
IPNS-->>SDK: sequenceNumber signatureVerified
SDK->>HW: enforceResolved seq generation versionFloor
HW-->>SDK: pass or throw SequenceRegressionError
SDK->>IPNS: updateFolderMetadataAndPublish
IPNS-->>SDK: newSequenceNumber
SDK->>Store: emitter.emit folder:updated UI updates here
SDK->>SDK: performScopeExitRotation
SDK->>IPNS: rotateReadFromNode root cut plus tail walk
IPNS-->>SDK: RotateReadResult readKey generation seq
SDK->>SDK: folderTree.set new readKey seq gen
SDK-->>UX: resolved
UX-->>Hook: resolved
alt ReconcileStaleError before publish
UX->>UX: retry with backoff max 5 attempts 30s
UX->>Hook: dispatchDeferExhausted toast on exhaustion
else SequenceRegressionError or GenerationRegressionError
UX->>Hook: Stale data from server rejected toast
else CannotWriteUntilRefetchError
UX->>Hook: Refresh access toast with action
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Hook as useFolderMutations
participant UX as runWithFailureUx
participant SDK as CipherBoxClient
participant HW as RotationHighWater IDB
participant IPNS as IPNS/API
participant Store as FolderStore + UI
Hook->>UX: "runWithFailureUx(() => renameItem(...))"
UX->>SDK: renameItem(...)
SDK->>IPNS: resolveIpnsRecord reconcile check
IPNS-->>SDK: sequenceNumber signatureVerified
SDK->>HW: enforceResolved seq generation versionFloor
HW-->>SDK: pass or throw SequenceRegressionError
SDK->>IPNS: updateFolderMetadataAndPublish
IPNS-->>SDK: newSequenceNumber
SDK->>Store: emitter.emit folder:updated UI updates here
SDK->>SDK: performScopeExitRotation
SDK->>IPNS: rotateReadFromNode root cut plus tail walk
IPNS-->>SDK: RotateReadResult readKey generation seq
SDK->>SDK: folderTree.set new readKey seq gen
SDK-->>UX: resolved
UX-->>Hook: resolved
alt ReconcileStaleError before publish
UX->>UX: retry with backoff max 5 attempts 30s
UX->>Hook: dispatchDeferExhausted toast on exhaustion
else SequenceRegressionError or GenerationRegressionError
UX->>Hook: Stale data from server rejected toast
else CannotWriteUntilRefetchError
UX->>Hook: Refresh access toast with action
end
|
…e stub Greptile PR findings: a persistent reconcile failure stacked a new Retry toast per click - the originating toast is now dismissed before the re-run via the notification id addNotification now returns; the not-implemented update-file stub threw inside runWithFailureUx, firing the degraded-cache notice for an operation that never reaches the rotation path. Entire-Checkpoint: 64095f48c9ee
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/sdk-core/src/rotation/engine.ts (1)
890-967: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftDirty-resume republish result is silently dropped, leaving caller's folderTree stale.
On the dirty-resume path (
rootResult.skipped === true,isDirty === true), whenfrontier.length > 0the code seedsparentTrackingfor the root and, once all frontier children finish, republishes the root viaupdateFolderMetadataAndPublish(line ~1042/1110) — this bumps the root's on-chain IPNSsequenceNumber. However, the final return (line 1192) unconditionally returnsundefinedwheneverrootResult.skippedis true, regardless of whether this dirty-resume walk actually republished the root with a new sequence number.Since
performScopeExitRotation(packages/sdk/src/client.ts) only refreshes its in-memoryfolderTreeentry whenrotationResultis truthy, this specific case leaves the client's cachedsequenceNumberstale after a real publish occurred — a subsequent same-session mutation on this folder will hitreconcileFolderSequence'sReconcileStaleErrorand defer permanently until a full reload. This is exactly the class of bug "ROT-07 Gap 2" fixed for the normal path, but the dirty-resume-with-pending-children case isn't covered.🛠️ Sketch: surface the republished sequence on the dirty-resume path too
if (rootResult.skipped) { // Resume path: root already committed in a prior run. const { isDirty, frontier } = await verifySubtreeClean(rootNodeIpnsName, rootReadKey, ctx); if (!isDirty) { jobRecord.status = 'complete'; if (jobRecord.persistCallback) await jobRecord.persistCallback(jobRecord); return undefined; } ... + // Track whether this dirty-resume walk republishes the root so the final + // return can report the up-to-date sequenceNumber even without a fresh key. } ... - if (rootResult.skipped) { - return undefined; - } + if (rootResult.skipped) { + return dirtyResumeRootRepublish + ? { readKey: rootReadKey, generation: rootNode.generation, sequenceNumber: dirtyResumeRootRepublish.newSequenceNumber } + : undefined; + }Also applies to: 1180-1199
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sdk-core/src/rotation/engine.ts` around lines 890 - 967, The dirty-resume path in rotateReadFromNode is dropping the result of a real republish, so the caller never learns the updated IPNS sequence. In the branch where rootResult.skipped is true and frontier.length > 0, make sure the code captures the sequence returned by the root republish/updateFolderMetadataAndPublish flow and returns a truthy rotation result instead of always falling through to undefined. Use the existing symbols rotateReadFromNode, parentTracking, and updateFolderMetadataAndPublish to locate the dirty-resume republish path, and only return undefined when no republish actually occurred.
🧹 Nitpick comments (4)
packages/sdk/src/__tests__/rotation-high-water.test.ts (1)
1-271: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd coverage for invalid live parameters passed to
enforceResolved/bumpGeneration/bumpSeq.Existing tests cover malformed stored values (Lines 136-183) but not malformed live params (e.g.,
generation: NaNor negativeseq) passed directly intoenforceResolved. GivenNaNcomparisons never throw in JS, this is the exact class of input that could silently bypass the regression gate — see the companion comment onrotation-high-water.ts.As per path instructions,
**/*.test.tsreview should "Focus on test coverage, edge cases, and test quality."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/sdk/src/__tests__/rotation-high-water.test.ts` around lines 1 - 271, Add tests in createRotationHighWater’s enforceResolved / bumpGeneration / bumpSeq coverage for invalid live inputs such as NaN, negative, and non-integer values. The current suite only checks malformed stored values, so extend the existing describe blocks in rotation-high-water.test.ts to assert these APIs fail closed or reject invalid parameters before any floor is updated. Use the same public symbols already referenced here—createRotationHighWater, GenerationRegressionError, and SequenceRegressionError—to keep the new cases aligned with the existing regression-gate behavior.Source: Path instructions
tests/web-e2e/tests/rotation-durability.spec.ts (1)
55-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAvoid pinning the rotation-state IndexedDB version in this spec
indexedDB.open('cipherbox-rotation-state', 1)duplicates the service’s DB version and will start throwingVersionErrorif that version changes. Open without a version argument, or share the service constant.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/web-e2e/tests/rotation-durability.spec.ts` around lines 55 - 56, The rotation-state IndexedDB open call in the rotation-durability spec is pinning a schema version that can drift from the service. Update the IndexedDB access in the spec around the req open/error handling to avoid hardcoding version 1, either by opening the database without a version argument or by reusing the same version constant used by the service’s rotation-state database.apps/web/src/services/rotation-driver.service.ts (1)
59-101: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winIndexedDB connections opened per call, never closed.
Every
putJobCheckpoint/deleteJobCheckpoint/getAllJobCheckpointscall opens a freshopenJobDB()connection that is never closed, accumulating open handles over a session and risking a blocked upgrade ifJOB_DB_VERSIONis ever bumped while old connections remain open. Consider caching a single connection promise at module scope and reusing it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/services/rotation-driver.service.ts` around lines 59 - 101, The IndexedDB helpers are opening a new connection on every call and never closing it, which can leave stale handles around. Update the openJobDB, putJobCheckpoint, deleteJobCheckpoint, and getAllJobCheckpoints flow to reuse a single module-scoped connection promise or cached IDBDatabase instance instead of reopening per operation. Ensure the shared connection is created once, reused by all checkpoint functions, and only closed intentionally if you add explicit teardown logic.apps/web/src/services/rotation-state.service.ts (1)
42-111: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winCache the IndexedDB connection instead of reopening per get/put.
idbGet/idbPuteach callopenRotationDB()independently and never close the resulting connection. Since this backsenforceResolved, called on every rotation-participatingresolveIpnsRecord(potentially every navigation), each resolve can open 1-2 freshIDBDatabaseconnections that are never released — real overhead on a hot path, and a futureDB_VERSIONbump would riskonblockedsince old connections stay open indefinitely. Cache a single connection-open promise at module scope and reuse it acrossget/putcalls.🔧 Proposed fix: cache the open connection
-function openRotationDB(): Promise<IDBDatabase> { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { ... }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} +let dbPromise: Promise<IDBDatabase> | null = null; +function openRotationDB(): Promise<IDBDatabase> { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { ... }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => { + dbPromise = null; + reject(request.error); + }; + }); + } + return dbPromise; +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/services/rotation-state.service.ts` around lines 42 - 111, The IndexedDB helpers in rotation-state.service are reopening the database for every idbGet/idbPut call and never releasing those connections. Update openRotationDB to cache a single module-scope Promise<IDBDatabase> (and reuse it from idbGet and idbPut) so repeated resolveIpnsRecord/enforceResolved calls share one open connection instead of creating new ones. Keep the existing openRotationDB, idbGet, and idbPut behavior the same aside from reusing the cached connection, and ensure the cache is initialized once and reused for all reads/writes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/hooks/useMutationFailureUx.ts`:
- Around line 80-100: The defer-exhaustion retry path is dropping the original
options object, so the retried mutation in
dispatchDeferExhausted/retryAfterExhaustion falls back to default
runWithFailureUx behavior. Thread opts through both helpers and pass it into the
retried runWithFailureUx call so any original behavior flags like
refreshWriteAccess are preserved when the user clicks Retry.
In `@apps/web/src/services/owner-reconcile.service.ts`:
- Around line 44-70: `decodeSentGrants` currently only fetches the first page
from `sharesControllerGetSentShares`, so reconcile can miss later sent grants.
Update `decodeSentGrants` in `owner-reconcile.service.ts` to loop through all
pages using the API client’s `limit` and `offset` (similar to `share.service.ts`
pagination), accumulating and decoding every share before returning. Keep the
existing per-share decode/error handling intact so a bad row still doesn’t abort
the full sweep.
In `@packages/sdk/src/client.ts`:
- Around line 617-642: The rotation refresh block in client.ts leaves sensitive
key material alive because `rotationResult.readKey` is copied into `folderTree`
but never wiped. In the `rotationResult` handling near `folderTree.set(...)`,
zero the original `rotationResult.readKey` buffer after creating the defensive
copy, while keeping the existing `oldFolderKey.fill(0)` cleanup intact. Use the
existing `rotationResult`, `folderTree`, and `oldFolderKey` flow as the anchor
for the fix.
- Around line 663-730: The MAX_NODES guard in enumerateMoveDescendants leaves
queued readKey buffers uncleared when the loop stops early. Update the cleanup
in enumerateMoveDescendants so any remaining queue entries are drained and their
readKey Uint8Array values are filled with zeros before returning, while
preserving the existing per-entry finally zeroing for dequeued items.
- Around line 515-554: The generation passed into reconcileFolderSequence is
currently read from folderTree instead of the freshly resolved IPNS record, so
the rotationHighWater check is using cached local state. Update the
resolveIpnsRecord flow and the reconcileFolderSequence path to carry the
resolved generation through this method, and use that value in enforceResolved;
if a fallback to folderTree is still needed, make it explicit in the contract
for resolveIpnsRecord and reconcileFolderSequence.
In `@packages/sdk/src/state/rotation-high-water.ts`:
- Around line 150-168: `enforceResolved` is comparing untrusted `generation` and
`seq` inputs without validating them first, so malformed values like NaN can
bypass the regression checks. Update the `enforceResolved` method in
`rotation-high-water` to validate the live inputs before any floor comparisons
or `bumpFloor` calls, using the same floor-value rules enforced by
`isValidFloorValue`/`readFloor`. Reject invalid `generation`, `seq`, and
`versionFloor` values up front so the `GenerationRegressionError` and
`SequenceRegressionError` paths remain fail-closed.
---
Outside diff comments:
In `@packages/sdk-core/src/rotation/engine.ts`:
- Around line 890-967: The dirty-resume path in rotateReadFromNode is dropping
the result of a real republish, so the caller never learns the updated IPNS
sequence. In the branch where rootResult.skipped is true and frontier.length >
0, make sure the code captures the sequence returned by the root
republish/updateFolderMetadataAndPublish flow and returns a truthy rotation
result instead of always falling through to undefined. Use the existing symbols
rotateReadFromNode, parentTracking, and updateFolderMetadataAndPublish to locate
the dirty-resume republish path, and only return undefined when no republish
actually occurred.
---
Nitpick comments:
In `@apps/web/src/services/rotation-driver.service.ts`:
- Around line 59-101: The IndexedDB helpers are opening a new connection on
every call and never closing it, which can leave stale handles around. Update
the openJobDB, putJobCheckpoint, deleteJobCheckpoint, and getAllJobCheckpoints
flow to reuse a single module-scoped connection promise or cached IDBDatabase
instance instead of reopening per operation. Ensure the shared connection is
created once, reused by all checkpoint functions, and only closed intentionally
if you add explicit teardown logic.
In `@apps/web/src/services/rotation-state.service.ts`:
- Around line 42-111: The IndexedDB helpers in rotation-state.service are
reopening the database for every idbGet/idbPut call and never releasing those
connections. Update openRotationDB to cache a single module-scope
Promise<IDBDatabase> (and reuse it from idbGet and idbPut) so repeated
resolveIpnsRecord/enforceResolved calls share one open connection instead of
creating new ones. Keep the existing openRotationDB, idbGet, and idbPut behavior
the same aside from reusing the cached connection, and ensure the cache is
initialized once and reused for all reads/writes.
In `@packages/sdk/src/__tests__/rotation-high-water.test.ts`:
- Around line 1-271: Add tests in createRotationHighWater’s enforceResolved /
bumpGeneration / bumpSeq coverage for invalid live inputs such as NaN, negative,
and non-integer values. The current suite only checks malformed stored values,
so extend the existing describe blocks in rotation-high-water.test.ts to assert
these APIs fail closed or reject invalid parameters before any floor is updated.
Use the same public symbols already referenced here—createRotationHighWater,
GenerationRegressionError, and SequenceRegressionError—to keep the new cases
aligned with the existing regression-gate behavior.
In `@tests/web-e2e/tests/rotation-durability.spec.ts`:
- Around line 55-56: The rotation-state IndexedDB open call in the
rotation-durability spec is pinning a schema version that can drift from the
service. Update the IndexedDB access in the spec around the req open/error
handling to avoid hardcoding version 1, either by opening the database without a
version argument or by reusing the same version constant used by the service’s
rotation-state database.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b685af9c-6515-449e-abe7-3b1627a304de
⛔ Files ignored due to path filters (4)
packages/api-client/openapi.jsonis excluded by!packages/api-client/**packages/api-client/src/generated/shares/shares.tsis excluded by!**/generated/**,!**/generated/**,!packages/api-client/**packages/api-client/src/models/index.tsis excluded by!packages/api-client/**packages/api-client/src/models/updateGrantDto.tsis excluded by!packages/api-client/**
📒 Files selected for processing (83)
.planning/REQUIREMENTS.md.planning/ROADMAP.md.planning/STATE.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-01-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-01-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-02-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-02-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-03-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-03-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-04-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-04-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-05-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-05-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-06-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-06-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-07-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-07-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-08-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-08-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-09-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-09-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-10-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-10-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-11-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-11-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-12-PLAN.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-12-SUMMARY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-CONTEXT.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-DISCUSSION-LOG.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-LEARNINGS.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-PATTERNS.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-RESEARCH.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-SECURITY.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-UI-SPEC.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-VALIDATION.md.planning/phases/68-web-integration-rotation-ux-and-durable-client-state/68-VERIFICATION.md.planning/todos/pending/2026-07-02-retire-dead-sdk-share-scaffolding.md.planning/todos/pending/2026-07-02-rotation-hardening-followups-from-pr-review.md.planning/todos/pending/2026-07-02-web-vitest-not-in-ci-and-ipns-service-test-broken.md.planning/todos/pending/2026-07-02-write03-refresh-access-path-has-no-live-trigger.mdapps/api/src/shares/dto/update-grant.dto.tsapps/api/src/shares/shares.controller.spec.tsapps/api/src/shares/shares.controller.tsapps/api/src/shares/shares.service.spec.tsapps/api/src/shares/shares.service.tsapps/web/src/components/NotificationToast.tsxapps/web/src/components/file-browser/useFileBrowserActions.tsapps/web/src/components/layout/AppHeader.tsxapps/web/src/components/layout/RotationStatusBadge.tsxapps/web/src/hooks/useAuth.tsapps/web/src/hooks/useFileOperations.tsapps/web/src/hooks/useFolderMutations.tsapps/web/src/hooks/useMutationFailureUx.tsapps/web/src/hooks/useSharedNavigation.tsapps/web/src/index.cssapps/web/src/lib/multi-tab-lock.tsapps/web/src/services/ipns.service.tsapps/web/src/services/owner-reconcile.service.tsapps/web/src/services/rotation-driver.service.tsapps/web/src/services/rotation-state.service.tsapps/web/src/services/share.service.tsapps/web/src/stores/notification.store.tsapps/web/src/stores/rotation.store.tsapps/web/src/stores/share.store.tsapps/web/src/styles/layout.csspackages/sdk-core/src/__tests__/rotation/engine.test.tspackages/sdk-core/src/index.tspackages/sdk-core/src/rotation/engine.tspackages/sdk-core/src/rotation/index.tspackages/sdk/src/__tests__/client-move-enumerate.test.tspackages/sdk/src/__tests__/client-rotation.test.tspackages/sdk/src/__tests__/client.test.tspackages/sdk/src/__tests__/collect-subtree-ipns-names.test.tspackages/sdk/src/__tests__/owner-reconcile.test.tspackages/sdk/src/__tests__/rotation-high-water.test.tspackages/sdk/src/client.tspackages/sdk/src/index.tspackages/sdk/src/share/index.tspackages/sdk/src/share/owner-reconcile.tspackages/sdk/src/state/rotation-high-water.tspackages/sdk/src/types.tstests/web-e2e/tests/rotation-durability.spec.tstests/web-e2e/tests/rotation-ux.spec.ts
Second-pass PR review fixes for PR 587: - rotation-high-water: enforceResolved now rejects NaN/negative/fractional live generation/seq inputs fail-closed instead of letting NaN comparisons silently pass the regression gate; bumpFloor never persists a malformed candidate; malformed cold-device versionFloor rejects rather than disabling the first-contact gate. Covered by 5 new unit tests. - useMutationFailureUx: thread the original opts through the defer-exhaustion manual Retry so a subsequent stale-write failure still offers the refreshWriteAccess path. - owner-reconcile: paginate the sent-grants fetch so grants beyond the first page are no longer silently unreconciled. - enumerateMoveDescendants: zero readKeys left queued when the MAX_NODES bound cuts the walk short. - rotation-durability e2e spec: open the rotation-state IndexedDB without a pinned version so the probe survives a future DB_VERSION bump. - todo: record the two deferred review findings — reconcile-gate cached generation source and the dirty-resume republish result drop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 9f0ec39529e4
|
Dispositions for the CodeRabbit review-body findings that have no inline thread:
|
Symmetric with the read-path guard in the web ipns.service: reconcileFolderSequence now throws before enforceResolved when the resolved record is not signature-verified, so a relay returning a forged inflated seq can never bump the durable floor and permanently wedge future mutations behind SequenceRegressionError. Regression test added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 46fe4bb06d13
Same anti-stacking rule already applied to the defer-exhausted Retry: the follow-up refresh/retry path dispatches its own notice, so drop the originating toast before re-running instead of stacking one per attempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 456303bfd721
Phase 68 — Web Integration: Rotation UX and Durable Client State
The web app now uses
rotateReadFromNodefor every revocation-triggering mutation, persists a durable IndexedDB generation + sequence high-water that survives page reload, and reconcilesfolderTreebefore any rotation publish.What's in here (12 plans)
SDK (
packages/sdk,packages/sdk-core){nodeId → highestGeneration}/{nodeId → highestSeq}monotonic-max state machine behind an injectedHighWaterStoreseam;enforceResolvedthrowsGenerationRegressionError/SequenceRegressionErrorfail-closed on any regression, with aversionFloorgate for cold-device first contact (ROT-07, SC#1/SC#4)executeLazyRotationand theaddShareKeys/reWrapForRecipientsper-mutation fan-out are deleted; delete/move/rename callrotateReadFromNodeon scope exit (SC#2)sequenceNumbermismatch defers withReconcileStaleError— never publishes against superseded state (SC#3, closes the silent-missed-revocation class from#489/#494)reconcileFolderSequenceroutes every mutation resolve throughenforceResolved, making the fail-closed gate live in real navigation/mutation flows (68-11 gap closure)rotateReadFromNodereturns the root's rotated{readKey, generation, sequenceNumber}andperformScopeExitRotationwrites it back intofolderTree, so a same-session second mutation self-heals instead of deferring until reload (68-12 gap closure)reMintGrantsRootedAtre-wraps grants for surviving recipients and deletes (never re-mints) revoked onesWeb (
apps/web)HighWaterStoreadapter with in-memory degradation (D-08), wired as therotationHighWatersingleton inuseAuthcipherbox-rotation-jobs, metadata-only — never key bytes) with resume-after-reload, plusnavigator.locksmulti-tab leader election with a designed-safe fallback (D-09)aria-live="polite", non-interactive) and the mutation-failure UX classifier: bounded defer retries (~5 attempts / ~30s) then terminal fail-closed toast; revoked co-writer gets a terminal no-action toast (D-01–D-06, WRITE-03)API (
apps/api)PATCH /shares/:shareId/grant— owner-only update ofreadDescriptorRef/rootGenerationafter rotation (D-10/D-11), with regenerated api-clientTests
rotation-durability.spec.ts+rotation-ux.spec.tsweb-e2e specs: real-browser reload durability proof, relay-regression rejection via captured/replayed IPNS record bytes, badge lifecycle, toast contract (run on the push-to-main web-e2e gate)Ship-pass results
68-SECURITY.md)describe.skipfrom#578)enumerateMoveDescendants(depth-2+ move descendants misclassified unreadable) fixed with a red-green regression pair, plus dead-code removal orphaned by the rotation cutoverDeferred (todos in
.planning/todos/pending/)ipns.service.test.tsbroken locally (pre-existing, fails on main too)ShareCallbacks,addShareKeysFn) + owner-reconcile double-fetch🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes