Skip to content

feat(core): create/load distinction mechanism for snapshot restoration (FEP-2548)#723

Draft
ENvironmentSet wants to merge 30 commits into
mainfrom
feature/fep-2548
Draft

feat(core): create/load distinction mechanism for snapshot restoration (FEP-2548)#723
ENvironmentSet wants to merge 30 commits into
mainfrom
feature/fep-2548

Conversation

@ENvironmentSet

@ENvironmentSet ENvironmentSet commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a create vs. load distinction to @stackflow/core so a stack can be initialized either by creating a fresh navigation (today's behavior) or by restoring a prior navigation from a snapshot. This is the core primitive that a persister (FEP-2546) and related consumers build on: it lets a plugin hand the store a previously-captured navigation history at initialization time and have the store faithfully reconstruct it.

The change is purely additive — with no snapshot provider present, initialization is observationally identical to today's code path.

New public surface (@stackflow/core)

  • StackSnapshot (+ NavigationEvent, the subset of the six navigation events) — $schema: "stackflow.snapshot.v1", carrying navigation events only.
  • actions.captureSnapshot() — filters and normalizes the event log into a snapshot (navigation events, ordered by eventDate, de-duplicated by id); callable from any hook.
  • provideSnapshot({ initialContext }) — optional plugin hook, polled synchronously at store creation (in React, inside <Stack/>'s first render — including SSR renders); a single snapshot slot (two or more non-null providers is a construction-time conflict error).
  • onLoadError({ error, initialContext }) + SnapshotLoadError — optional plugin hook and error type with a three-way cause (incompatible-schema / invalid-events / empty-navigation); returning { recover: "create" } resumes fresh creation, otherwise the error is thrown out of makeCoreStore.
  • onInit now receives initializedBy: "create" | "load".

Behavior

  • Create path — unchanged when no provider is present (existing initialization, existing effects).
  • Load path — structural check → registration check (every activity-introducing Pushed/Replaced must name a registered activity) → rebase of event timestamps → replay through the existing aggregate + validateEvents → postcondition (at least one entered activity) → on failure, routed to the provider's onLoadError.

Non-breaking

No new domain events, no new stack-state properties, no new app-developer-facing surface in the React layer, no new makeCoreStore options, and no changes to aggregate / validateEvents / the reducers / overrideInitialEvents. Existing consumers (React integration and all extensions) type-check unchanged.

Tests & release

  • New core/src/*.spec.ts suites cover the public contract, the create/load sequences, the invariants, and a snapshot round-trip / persister-style integration scenario. The full @stackflow/core suite is green with no regression to existing tests; type-check and lint are clean.
  • Includes a @stackflow/core minor changeset.

Notes for reviewers

  • This branch also carries the design document and planning notes (design-fep-2548-init-load-mechanism.md, the run plan, glossary) committed during design. They're kept here for context; curate what should actually land on main before marking ready. The implementation's code comments reference this design document's section markers — self-contained while the doc is present, so if you drop the doc, sweep those references.
  • Two open design questions surfaced during implementation (both non-blocking, out of this change's scope): (1) the behavior when a provideSnapshot/onLoadError hook itself throws is left undefined by the design and is intentionally not pinned by tests; (2) the design's "all-popped history" example is imprecise (a root activity can't be popped, so that exact state is unreachable) — the empty-navigation mechanism itself is sound, so no design change is required.

Opened as a draft for review and curation before merge.

🤖 Generated with Claude Code

ENvironmentSet and others added 8 commits July 7, 2026 23:49
- CONTEXT.md: add Navigation History term; narrow load fidelity scope to
  navigation history (R12); snapshot codec is outside core contract (R13)
- run-plan-fep-2548-mechanism.md: diverge-converge-refine run plan
  (7 seeded generators -> curation -> league tournament -> review-loop)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed mechanism design for distinguishing Stack init (creation) from
load (snapshot restoration) in stackflow core.

- Snapshot = navigation event log; load = replay through existing aggregate
  (reachability + fidelity hold by construction — no new validator)
- Public contract: StackSnapshot, actions.captureSnapshot(), provideSnapshot
  / onLoadError / onBeforeInitialPush hooks, onInit(createdBy) one-shot signal
- Non-breaking: unchanged creation path when no snapshot provided;
  overrideInitialEvents preserved and treated as init
- Load validation rejects any snapshot event (Pushed/Replaced/future
  activity-introducing) whose activity is unregistered in load-time config
- Resolves the four deferred decisions; proves persister / guard / history-sync
  consumers close over the core contract alone

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ok, rename init→create path

Maintainer-confirmed revision of the init/load distinction design.

- Remove onBeforeInitialPush; public surface 5 → 4. Create-path entry
  interception is done via the existing overrideInitialEvents chain
  (inspect / strip = block / replace = redirect), since initial events are
  pre-aggregate array data — stripping is the pre-aggregate equivalent of
  preventDefault, so no dedicated hook is warranted. Guard now requires zero
  new core surface; ordering discipline + an onInit validation belt cover it.
- Terminology: createdBy → initializedBy ("create" | "load"); the "init"
  creation path is renamed "create" (built from scratch). "initialize" is the
  bootstrap umbrella (onInit / store.init, fires on both paths), so
  onInit({ initializedBy: "load" }) is no longer self-contradictory. The
  initial* inputs (initialEvents, initialActivity, …) and the Initialized
  event keep their names.
- CONTEXT.md ubiquitous language updated: 초기화(Init) → 생성(Create).
- The anti-unification rejection (post-effect pushState duplication, loader
  plugin pause counterexample) still stands; only "realize it via a dedicated
  hook" is reversed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the FEP-2548 create/load verification suite (core/src/*.spec.ts),
1:1 with the confirmed test plan, plus type-signature stubs for the new
public surface so the suite compiles. Implementation is deferred: the new
harness is red at the unimplemented points (stub throw / assertion), while
the existing core suite stays green (no behavioral change).

New public surface (stubs, per design §3):
- StackSnapshot / NavigationEvent types (navigation-event subset union)
- SnapshotLoadError class with a three-kind cause
  (incompatible-schema / invalid-events / empty-navigation)
- StackflowActions.captureSnapshot() — throws "not implemented yet"
- StackflowPlugin.provideSnapshot? / onLoadError? optional hooks
- StackflowPluginHook gains initializedBy: "create" | "load", with a
  "create" placeholder at the onInit call site

Suites (core/src):
- captureSnapshot: snapshot format + capture edges (transition/pause)
- provideSnapshot: single snapshot slot, conflict, null/undefined
- createPath: create sequence, override interception, N1/N2, no-hook
- loadPath: structure/registration/replay/postcondition/routing, L1/L3/L6, sync
- rebase: order (RB1), settle (RB2), clock reversal (RB4), id preserve (RB5)
- initializedBy: create signal, no persisted trace
- snapshotRoundtrip: capture∘load∘capture stability (L5)
- persisterRoundtrip: capture→JSON→load round trip + corrupt-snapshot recovery

The empty-navigation "all-popped" case uses a pops-only snapshot
(events:[Popped]) because core cannot pop the root activity, so a
Pushed→Popped history never reaches zero enter-state activities; the
pops-only snapshot reaches that state and preserves the item's intent
(non-empty events, zero enter activities).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fill the create/load stubs so a stack knows how it was born — freshly
(`create`) or restored from a snapshot (`load`) — and close the snapshot
round-trip (capture → persist → load) through additive plugin contracts.
A store with no snapshot provider takes the same code path as before.

- captureSnapshot() normalizes the raw event log (sort by eventDate,
  dedupe by id, keep only navigation events), so it reflects pause-queued
  events and the array order equals the replay order.
- makeCoreStore polls provideSnapshot across plugins once: zero non-null
  -> create; exactly one -> load; more than one -> a plain creation error
  naming the conflicting keys (not a SnapshotLoadError, not routed).
- loadSnapshot reconstructs the stack by replaying the snapshot's
  navigation events through the existing aggregate machinery: structure
  check (incompatible-schema), registration check over Pushed and Replaced
  (invalid-events), rebase to a settled past window, replay, then an
  enter-state postcondition (empty-navigation). Static info
  (transitionDuration, registered set) is re-derived from the current
  config; id/activityId/stepId are byte-preserved, only eventDate rebased.
- A failed load is routed only to the providing plugin's onLoadError;
  { recover: "create" } resumes the create path without re-polling, and
  void/no-handler throws out of makeCoreStore.
- onInit now receives the real initializedBy signal.

No new domain events, stack state properties, makeCoreStore options, or
changes to aggregate/validateEvents/reducers/overrideInitialEvents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1c58ead

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@stackflow/core Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f8a82520-6021-49af-a125-cc4d7aec1e03

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fep-2548

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026

Copy link
Copy Markdown
@stackflow/core

yarn add https://pkg.pr.new/@stackflow/core@723.tgz

@stackflow/link

yarn add https://pkg.pr.new/@stackflow/link@723.tgz

@stackflow/plugin-basic-ui

yarn add https://pkg.pr.new/@stackflow/plugin-basic-ui@723.tgz

@stackflow/plugin-blocker

yarn add https://pkg.pr.new/@stackflow/plugin-blocker@723.tgz

@stackflow/plugin-devtools

yarn add https://pkg.pr.new/@stackflow/plugin-devtools@723.tgz

@stackflow/plugin-google-analytics-4

yarn add https://pkg.pr.new/@stackflow/plugin-google-analytics-4@723.tgz

@stackflow/plugin-history-sync

yarn add https://pkg.pr.new/@stackflow/plugin-history-sync@723.tgz

@stackflow/plugin-lifecycle

yarn add https://pkg.pr.new/@stackflow/plugin-lifecycle@723.tgz

@stackflow/plugin-renderer-basic

yarn add https://pkg.pr.new/@stackflow/plugin-renderer-basic@723.tgz

@stackflow/plugin-renderer-web

yarn add https://pkg.pr.new/@stackflow/plugin-renderer-web@723.tgz

@stackflow/plugin-sentry

yarn add https://pkg.pr.new/@stackflow/plugin-sentry@723.tgz

@stackflow/plugin-stack-depth-change

yarn add https://pkg.pr.new/@stackflow/plugin-stack-depth-change@723.tgz

@stackflow/react-ui-core

yarn add https://pkg.pr.new/@stackflow/react-ui-core@723.tgz

@stackflow/react

yarn add https://pkg.pr.new/@stackflow/react@723.tgz

commit: 1c58ead

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploying stackflow-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1c58ead
Status: ✅  Deploy successful!
Preview URL: https://79058110.stackflow-demo.pages.dev
Branch Preview URL: https://feature-fep-2548.stackflow-demo.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
stackflow-docs 1c58ead Commit Preview URL Jul 10 2026, 09:03 AM

@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

리뷰 — create/load distinction mechanism (FEP-2548)

총평

방향과 뼈대는 탄탄합니다. 기존 aggregate 재생 기계를 그대로 활용하는 로드 경로, 단일 스냅샷 슬롯 + 충돌 시 즉시 실패, onLoadError의 3-way cause 설계 모두 설계 문서와 잘 정합하고, 프로바이더 없는 create 경로가 기존과 관측상 동일하다는 주장도 실증됩니다(전 워크스페이스 빌드+타입체크 통과, 코어 137개 테스트 전부 green — 별도 체크아웃에서 직접 확인). 다만 머지 전 반드시 처리해야 할 것 2건(구조 검사 강화, 루트 문서 큐레이션)과, 설계 수준에서 기록해야 할 경계 이슈 1건이 있습니다.

검증 방식: PR head(d83506e)를 별도 클론에 체크아웃 → 9개 관점(6개 기본 + 완결성 비평 후속 3개)으로 병렬 리뷰 → 발견 26건 각각을 3개 렌즈(코드 반박 / jest 재현 / 설계 의도 대조)로 적대적 검증 → 23건 확정, 3건 기각. "재현됨" 표시는 공개 API를 통해 실제 jest 스펙으로 재현된 것입니다.


🔴 머지 전 필수

1. [major/correctness] 구조 검사가 payload 없는 이벤트를 통과시켜 — 조용히 오염된 스택이 만들어지고 onLoadError가 우회됨 · 재현됨

assertSnapshotStructure(core/src/loadSnapshot.ts:109-118)는 항목당 id가 string인지와 name이 6종인지만 봅니다. activityId/activityParams가 아예 없는 {"id":"x","name":"Pushed","activityName":"Home"}이 구조 검사→등록 검사→rebase→aggregate→사후조건을 전부 통과해서, id: undefined, params: undefined인 활성 액티비티가 enter-done으로 올라옵니다. 재현 스펙에서 onLoadError called: false 확인 — 즉 이 메커니즘의 핵심 약속(실패는 SnapshotLoadError로 크게, 프로바이더에게 create 복구 기회 제공 — 설계 §5 R4/L3)이 정확히 스토리지 오염 시나리오에서 무너집니다. 오염은 로드 경계에서 멀리 떨어진 곳(activity.id로 키잉하는 렌더링, history-sync의 id 기반 popstate 방향 판정)에서 터집니다.

같은 계열 갭 2건도 재현됨: (a) 스냅샷 내 중복 id는 통과 후 uniqBy가 앞 이벤트를 조용히 드랍(두 Pushed가 하나의 액티비티로 로드됨), (b) activityName 누락은 구조 결함인데 등록 검사에서 invalid-events로 오분류.

제안: assertSnapshotStructure를 이벤트 이름별 payload 형태 검증으로 확장(Pushed/Replaced: activityId/activityName string + activityParams object; Step*: stepId/stepParams)하고 스냅샷 내 중복 id 거부 — 전부 incompatible-schema로 매핑하면 onLoadError 복구 경로가 살아납니다. 설계 문서 §4.1 3단계("항목이 6종 네비게이션 이벤트여야 한다")와도 이쪽이 정합합니다.

2. [major/hygiene] 루트의 run-plan 2개는 public main에 실리면 안 됨

PR 본문이 스스로 큐레이션을 요청한 부분의 구체적 답입니다:

  • run-plan-fep-2548-impl.md / run-plan-fep-2548-mechanism.md → 삭제 — 에이전트 세션 테이블의 모델 바인딩, 내부 오케스트레이션 메모리 키가 그대로 노출됨(89-99행, 127-142행)
  • design-fep-2548-init-load-mechanism.md → 유지하되 루트 밖으로 이동(예: core/docs/ 또는 designs/) — 코드 주석이 참조하는 문서이므로 유지 자체는 타당
  • CONTEXT.md(유비쿼터스 랭귀지 용어집) → 이동 또는 삭제

문서를 옮기면 아래 minor #11의 마커 주석 스윕과 함께 처리해야 합니다.


🟠 Major (설계 수준 — 이 PR 코드 수정 필수는 아니나 기록·후속 필요)

3. 로드 검증 경계가 onInit 이전에 끝남 — 복원 전용 실패가 onLoadError 계약을 탈출하고, React 렌더 재시도가 provideSnapshot을 재폴링함 · 재현됨 (3/3)

로드의 4단계 검증은 aggregate에서 끝나는데, 복원된 스택의 첫 실소비자는 onInit 훅들입니다. 실제 historySyncPlugin을 물려 재현한 결과: routes에 없는(등록만 된) 액티비티가 스냅샷에 있으면 onInitactivityRoutes.find(...)!makeTemplate(undefined)makeCoreStore try 블록 밖에서 던져서 onLoadError는 영영 호출되지 않고 프로바이더는 스토리지를 청소할 기회를 잃습니다 → 매 부팅마다 반복되는 백화면. 게다가 <Stack/>useMemo 안에서 store 생성+init()이 실행되므로 React의 렌더 재시도가 provideSnapshot재폴링하고(read-and-clear 프로바이더는 스냅샷 유실), history는 반쯤 변경된 채 남습니다.

경계 위치 자체는 설계가 정한 대로라 이 PR의 코드 버그는 아니지만, 설계의 soft spot이 실증된 것입니다. 최소한 설계 문서에 이 한계를 명시하고 FEP-2546(persister)에서 다룰 항목으로 등재하길 권합니다(예: onInit 팬아웃까지 로드 오류 경계 확장, 또는 복원 액티비티의 라우트/파라미터 사전 검증). 아래 #13의 §7.3 cold-start 크래시 벡터는 이 항목의 부분집합입니다.

4. [tests] provideSnapshot/onLoadError가 throw하는 경우를 고정하는 테스트가 전무 — 그리고 persisterRoundtrip.spec.ts의 미믹 자체가 취약 패턴을 시연 중

provideSnapshot 폴링(makeCoreStore.ts:104-116)은 try/catch 밖입니다. 스토리지가 잘린 채 저장된 경우(quota eviction, 중단된 write) JSON.parseSyntaxError가 raw로 전파돼 앱이 부팅 불가가 되고, corrupt 스냅샷을 버리라고 존재하는 onLoadError는 안 불립니다. PR이 이 동작을 "미정의, 논블로킹"으로 자진 신고한 건 알지만, 문제는 persisterRoundtrip.spec.ts:50-51, 97-98의 참조 구현이 정확히 그 취약 패턴(provideSnapshot 안에서 직접 JSON.parse)을 보여주고 있다는 것 — persister 작성자들이 복사할 코드입니다. 최소한 현재 전파 동작을 핀하는 테스트 2개를 추가하고, 미믹은 decode 실패를 자체 처리(null 반환 + 스토리지 청소)하는 모양으로 고치길 권합니다.


🟡 Minor

5. rebase가 설계 RB3의 일반 케이스(정적 이벤트 뒤 창 배치)를 구현하지 않음loadSnapshot.ts:136-141은 정적 이벤트 date를 안 보고 (now − transitionDuration) − (len − idx) 고정 1ms 간격만 씁니다. react 통합의 정적 이벤트는 transitionDuration × 2만 백데이트되므로(integrations/react/src/stackflow.tsx:90-105), 이벤트 ~350개(step 많은 히스토리면 도달 가능, 스냅샷은 Popped도 영구 보존이라 무한 성장) 이상이면 초기 이벤트들이 Initialized/ActivityRegistered보다 먼저 폴드됩니다. N=400 실험으로 오늘은 결과가 정확함을 확인했지만(리듀서가 폴드 순서에 불감), 주석은 "RB1–RB5 구현"을 주장하고 있어 설계와 코드가 어긋난 상태입니다. RB3대로 창 안 배치를 구현하든, 퇴화 영역 의존을 의도로 명시하든 하나로 정리하고 — 이 퇴화 순서를 핀하는 테스트도 없습니다(모든 스위트가 정적 이벤트를 60초 백데이트해서 이 영역을 우회함). 리듀서가 나중에 순서 민감해지면 긴 히스토리 복원만 조용히 깨집니다.

6. 스냅샷 값 의미론이 shipped 표면에 미문서화captureSnapshot은 라이브 이벤트 객체 참조를 그대로 반환하고(반환 스냅샷 변이 = 스토어 오염, 재현됨), 로드 측도 얕은 spread라 activityParams를 프로바이더와 공유합니다. 설계 §3.2는 "구조 공유, 변이는 UB"라고 이미 선언하므로 동작 자체는 as-designed지만, 그 경고가 StackflowActions.ts:22-26/StackSnapshot.ts의 JSDoc에는 없습니다. 추가로 JSON 코덱 통과 시 undefined 값 키가 드랍되어 복원 후 Object.keys/deep-equal이 달라지는 점도 같은 자리에서 "직렬화 가능성은 소비자 책임" 한 줄로 문서화하면 됩니다.

7. 이벤트 id가 제2의 정렬 채널인데 검증도 문서화도 없음 · 재현됨aggregate.ts:109가 액티비티 배열을 activity.id 사전순으로 정렬합니다. rebase는 eventDate만 재작성하고 id는 보존하므로, 수작업/변환된 스냅샷의 비단조 id는 렌더 순서와 history-sync의 popstate 방향 판정을 뒤집습니다. 최소한 "id는 배열 순서와 단조여야 함"을 검증(invalid-events)하거나 문서화가 필요합니다.

8. provideSnapshot 반환 타입이 undefined를 거부 — 타입은 StackSnapshot | null인데 바로 위 JSDoc·체인지셋·런타임(?? null)은 전부 undefined 허용을 말합니다. 문서대로 return;을 쓴 작성자는 컴파일 에러. | void(자매 훅 onLoadError의 선례 그대로) 또는 | null | undefined로 넓히면 끝. 쉬운 수정입니다.

9. 타입 레벨 브레이킹 + 체인지셋 문구/오타StackflowActions.captureSnapshot(필수 멤버)와 StackflowPluginHookinitializedBy(필수 인자)는 mock 생성자·훅 호출자(wrap-and-forward 메타 플러그인)에게는 컴파일 브레이킹입니다. minor 범프 자체는 repo 선례(pause/resume가 minor로 추가)와 일치해 OK지만, "entirely additive" 문구는 과장이므로 타입 레벨 주의 한 문장을 추가하세요. 체인지셋의 훅 시그니처 (({ initialContext })) 이중 괄호 오타도 수정(CHANGELOG에 그대로 실립니다).

10. 복구 경로의 create 의미론 미검증recover:"create"createStack()overrideInitialEvents 체인과 initial-activity 핸들러를 온전히 태우는지 아무 테스트도 안 봅니다(현 테스트 3개는 결과 액티비티 목록만 확인). persister+history-sync 조합이 명시된 실사용 구성인 만큼, 실패 프로바이더 + overrideInitialEvents 플러그인 공존 테스트 하나 추가 권장.

11. 주석 정책(WHY only) 위반 + 마커 스윕 목록loadSnapshot.ts:14-24(4개 검사 열거 — 본문과 SnapshotLoadError.ts:2-9에 이미 있는 매핑의 3중 중복), :94-97, makeCoreStore.ts:67-68 등이 코드를 다시 서술합니다. WHY 절만 남기고 정리 권장. 그리고 §/R/L/RB/C 마커를 참조하는 주석이 코드·테스트에 16곳(makeCoreStore.ts:100,103,143,145 / SnapshotLoadError.ts:18,19 / loadSnapshot.ts:38,122 / interfaces/StackflowPlugin.ts:145,150,153 / loadPath.spec.ts:72,226,286,493,573) — 문서 이동/삭제 시 전부 스윕해야 하며, 현재는 마커가 어느 문서에 정의됐는지 가리키는 포인터도 없습니다.

12. 문서 사이트 미커버write-plugin.{en,ko}.mdx에 새 공개 표면(provideSnapshot/onLoadError/captureSnapshot/StackSnapshot/initializedBy)이 전무. FEP-2546과 함께 싣는 유예는 타당하나, 명시적 결정으로 기록해 두세요(루트 CLAUDE.md의 훅 목록도 stale).

13. (설계 문서 보강) 복원 액티비티의 loaderData 부재 — 로더 주입 지점 2곳(loaderPlugin의 overrideInitialEvents, onBeforePush/Replace)이 로드 경로에선 둘 다 안 불려 useLoaderData()가 undefined, initialLoaderData도 조용히 무시됩니다. §7.1.3(프로바이더가 재유도)을 참고 시나리오에서 persister 필수 요건으로 승격하길 권합니다.


⚪ Nit

  • PR 본문 문구: "polled synchronously at initialization" → 실제로는 store creation(makeCoreStore, React에선 <Stack/> 첫 렌더의 useMemo, SSR 렌더 포함) 시점입니다. 브라우저 전용 스토리지 접근 가이드에 영향 있는 차이라 문구 수정 권장.
  • 에러 타입 비일관: 다중 프로바이더 충돌은 plain Error, 로드 실패는 SnapshotLoadError — 에러 바운더리에서 판별 불가. 의도라면 문서화, 아니면 typed error로.
  • 미고정 계약 엣지 3건: onLoadError{recover:"retry"} 같은 truthy 반환 시 rethrow / 중복 id 스냅샷의 last-wins / 미지 프로퍼티 수용(전방 호환) — 각 한 줄짜리 테스트로 핀 가능.
  • 커밋 이력: "opus 4.8", "add impl plan" 등은 squash-merge(PR 제목을 subject로)로 해결 — 리라이트 불필요, 큐레이션 커밋은 위에 쌓으면 됩니다.
  • stack-depth-change: 유일한 wrap-and-forward 사이트로, initializedBy가 소비자까지 전달 안 됨 — 코어 변경 불필요, 확장 다음 손볼 때 optional 필드로 추가하면 되는 후속.

검증에서 기각된 후보들 (참고)

  • 딥링크 vs 복원 스택 우선순위 (0/3) — 로드가 overrideInitialEvents를 건너뛰는 건 R6("load 진입은 가로채기 대상 아님")의 의도된 메커니즘이고, 딥링크 중재는 FEP-2001로 명시적 유예됨.
  • 비-Pushed initialEvents가 스냅샷 재생에 병합 — 기계적으로 사실이나 실존 소비자에게 도달 불가능한 시나리오.
  • NavigationEvent 이름이 history-sync의 기존 공개 타입과 충돌 — 핵심 주장이 검증에서 무너짐.

권고 요약

머지 전: #1 구조 검사 강화 + #2 문서 큐레이션(+ #8, #9는 몇 분짜리 수정이니 같이). 이 PR 또는 직후: #4, #5, #10 테스트 보강과 #6, #7, #11 문서화/주석 정리. FEP-2546로 이월: #3 경계 확장, #12, #13. 전반적으로 구현 품질과 테스트 밀도는 높은 편이고, 지적사항 대부분이 "설계 문서가 이미 아는 것을 shipped 표면이 아직 모르는" 종류입니다.

🤖 Generated with Claude Code

@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

리뷰 — 설계 충실성 · 요구사항(FEP-2548) 검증

결론: 승인 권장 (draft 해제 전 minor 수정 몇 건 권장). 설계 문서(§3 계약 · §4 시퀀스 · §5 불변식)를 충실히 구현했고, Linear 요구사항 R1–R13과 완료 기준을 모두 충족합니다. Blocking 이슈 없음 — 발견 사항은 전부 minor(changeset 표기 1, 방어 강화 1, 테스트 공백 2, 설계 문서와의 자구 차이 1, 관찰 가능한 미세 차이 1)입니다.

검증 방법: 7개 차원(공개 계약 / 생성 시퀀스 / 불변식 / 요구사항 / 코드 정확성 / non-breaking / 테스트)으로 병렬 정밀 조사 후, finding 각각을 2개 렌즈(반증 시도 · 영향 평가)로 적대 재검증. 추가로 핵심 구현·스펙 직접 재독 + 빌드/타입체크/테스트 재현 실행.

실행 검증

  • 테스트: core 22개 스위트 / 137개 테스트 전부 통과 (신규 스펙 8개 + 기존 스위트 green — N1 회귀 없음)
  • 타입체크: core 단독 + yarn build 후 레포 전체 yarn typecheck 통과 — "기존 소비자 무변경 타입체크" 주장 검증됨
  • 린트: Biome 경고 11건은 전부 main에도 존재하는 기존 경고 (diff가 새로 만든 경고 0)
  • diff 범위: core/src 외 소스 변경 0 (changeset + md 문서뿐)

설계 충실성 (§3–§5)

  • §3.1 StackSnapshot$schema 리터럴, 탐색 이벤트 6종 제한, 정적/pause 이벤트 미포함: 설계 코드 블록과 일치
  • §3.2 captureSnapshot — aggregate 전처리와 동일 정규화(같은 정렬 비교자 + 같은 uniqBy 재사용) 후 탐색 이벤트 필터. raw events.value를 읽으므로 pause 중 큐잉 이벤트도 캡처
  • §3.3 provideSnapshot — 전원 동기 폴링, 선언적·순서 무관, makeCoreStore 옵션 진입점 없음. 2개 이상 non-null → 충돌 key 명시 일반 Error(SnapshotLoadError 아님, onLoadError 비라우팅) — 양방향 플러그인 순서로 테스트됨
  • §3.4 에러 계약 — 3분류 cause, 공급자 전용 라우팅, {recover:"create"} 시 재폴링 없이 create 재개, void/부재 시 throw. 등록 검사가 Replaced까지 확장(§10-S6a 간극 보완)되고 런타임 validateEvents는 무변경(R8)
  • §3.5 / §3.6 — create 전용 훅 신설 없음, overrideInitialEvents 무변경(createStack()은 기존 코드의 verbatim 이동). initializedByonInit 인자로만 존재 — StackflowPluginHookonInit 전용임을 확인했으므로 타입 확장 안전. 복원 activity의 enteredBy 원본 보존
  • §4 시퀀스·재기저 — create/load 분기 설계 순서 그대로, load는 체인·초기 activity 핸들러·옵션 초기 Pushed 전부 스킵. RB1/RB2/RB4/RB5 정확(최대 date now−T−1로 리듀서 판정 엄격 충족, aggregate에 같은 now 전달로 시계 재판독 레이스 없음). RB3만 자구 차이(finding 1)
  • §5 불변식 — C1–C4, N1–N2, L1–L6 전부 코드로 강제, 대부분 스펙으로 고정 (예외: finding 3·4·6)
  • 공개 표면index.ts 신규 export가 설계 표면과 정확히 일치, react 표면 0

요구사항 커버리지 (Linear comment-3dbff893)

항목 판정 근거 요약
R1–R10, R12, R13 ✅ 충족 각각 구현 + 테스트로 고정
R11 충실 재구성 ⚠️ 충족·테스트 일부 공백 메커니즘은 구성적 성립(무변경 aggregate 재생), load 후 pop/step 균질성만 미고정 (finding 3)
완료 정의 ✅ 충족 persisterRoundtrip.spec.ts — persister 모사 플러그인이 core API만으로 캡처→JSON 보존→load 왕복을 end-to-end로 닫음
비목표 5건 ✅ 전부 준수 late load 불가(재폴링 없음 테스트됨) · create 세분화 어휘 없음 · 지속 속성 없음 · react 표면 없음 · 마이그레이션 없음
설계 이연 4건 ✅ 전부 해소 가로채기=기존 체인 · 형식=이벤트 이력 · activity 출처 비표현 · 에러=콜백+기본 throw

Findings (전부 minor/info — blocking 없음)

1. [minor] 재기저가 RB3의 일반 케이스 창 배치를 구현하지 않음loadSnapshot.ts:136-141
설계 RB3는 "정적 이벤트 date보다 뒤의 창 안에 배치(소수 date로 개수 무관)"를 일반 규칙으로 명시하는데, 구현은 정적 이벤트 date를 참조하지 않는 고정 1ms 정수 간격(settledUpperBound − (N − index))입니다. 스냅샷 이벤트가 transitionDuration(ms) 개수 이상이면(기본 350개+) 가장 이른 이벤트들이 Initialized/ActivityRegistered보다 앞서 fold됩니다. 기능적으로는 안전합니다 — 설계 스스로의 퇴화 창 논증(transitionDuration=0 폴백도 enter-done, 검증은 순서 무관)이 리듀서 코드로 성립함을 확인했습니다. 다만 stack.events 순서가 플러그인에 관찰 가능하고, 설계 문서와 구현이 다른 말을 합니다. → 소수 간격으로 창 안에 채우거나, RB3를 "고정 간격 + 퇴화 안전 논증"으로 개정해 문서-코드를 일치시키길 권합니다.

2. [minor] 구조 검사 깊이 — payload 결손 이벤트가 4개 검사를 전부 통과loadSnapshot.ts:109-118
{ id, name: "Pushed", activityName: "<등록된 이름>" }처럼 activityId/activityParams가 떨어져 나간 항목(소비자 codec 버그, 부분 손상)은 구조 검사(id·name만 확인)→등록 검사→validateEvents→사후조건을 모두 통과해 activity.id === undefined인 손상 스택이 조용히 복원됩니다. 설계 §3.4의 검사 범위 명세("id/name 결손"까지)에는 충실하므로 구현 잘못은 아니지만, R4의 정신("유효하지 않은 스냅샷으로 정상 상태 Stack이 몰래 만들어지지 않는다")에 어긋나는 코너입니다. → 이벤트 종류별 필수 필드(Pushed/Replaced: activityId·activityName·activityParams, Step*: stepId·stepParams) 존재 검사를 incompatible-schema에 추가 권장.

3. [minor] 테스트 공백 — load 후 pop/step 미검증 (R11 균질성)rebase.spec.ts:116 인근
load 후 항해는 push만 테스트돼 있습니다. 복원 activity를 pop(exit 전환, 아래 activity 재노출)하거나 step 항해(StepPushed는 eventDate 기반 최신 활성 activity 타깃 — 재기저와 직접 상호작용)하는 테스트가 없어, 재기저 회귀가 pop/step 타깃팅을 깨도 현 스위트는 통과합니다.

4. [minor] 테스트 공백 — pause 중 캡처 스냅샷의 load 사후조건 미검증captureSnapshot.spec.ts:266 인근
§3.2가 계약으로 명문화한 "재생 결과는 pause가 없었던 것처럼 큐잉된 항해가 전부 적용된 상태"가 캡처 내용까지만 테스트되고, 그 스냅샷을 실제 load해 큐잉됐던 activity가 정착 상태로 물화되는지 확인하는 테스트가 없습니다(전환 중 캡처는 load 측 테스트가 있는데 pause만 반쪽).

5. [minor] changeset이 cause를 문자열 enum처럼 서술.changeset/fep-2548-create-load-distinction.md (PR body 동일 문장 포함)
실제 타입은 cause: { kind: ... } 판별 객체인데, 릴리즈 노트만 읽은 플러그인 저자는 error.cause === "invalid-events"를 쓰게 되고 이는 영원히 false입니다. → cause.kind 표기로 수정 권장.

6. [info] N1 미세 차이 — initialContext 공유 객체화makeCoreStore.ts:55
main에서는 options.initialContext ?? {}overrideInitialEvents 호출마다 평가돼 미지정 시 플러그인별 새 객체였는데, 이제 한 객체를 체인 전체(+신규 훅)가 공유합니다. 플러그인이 args.initialContext를 변이하면 뒤 플러그인에 새어 나가는 것이 이전엔 불가능했고 지금은 가능합니다. 실사용 영향은 거의 없지만 N1("관찰상 동일")의 자구에 걸리는 유일한 지점이라 기록해 둡니다.

7. [참고] 중복 id 스냅샷의 경계 동작 미고정
소비자가 변환한 스냅샷에 중복 id가 있으면 aggregate의 uniqBy가 조용히 뒤 항목을 버리는데, 이것이 의도된 경계인지 어디에도 고정돼 있지 않습니다. 의도라면 테스트 한 줄로 고정할 가치가 있습니다.

PR body의 open questions에 대한 소견

  1. 훅 자체가 throw하는 경우 미정의 — 확인 결과 raw로 전파되어 makeCoreStore 밖으로 나가며 부분 상태가 관찰될 경로는 없습니다. 미정의로 두는 것에 동의합니다.
  2. "전부 pop된 이력" 예시의 부정확성loadPath.spec.ts:583이 pop-only 이력으로 empty-navigation을 정확히 재현하고 있어 메커니즘은 건전합니다. 설계 수정 불요 판단에 동의합니다.

기타

  • 코드 주석이 설계 문서의 §마커를 참조하므로, merge 전 문서 큐레이션 시 주석 sweep이 실제로 필요합니다 (PR body가 이미 자각).
  • 스펙 품질은 전반적으로 우수합니다 — 재생 결과 기준으로 계약을 고정하고 재기저 date 구체값 같은 내부에 과적합하지 않아 §9 compaction 진화 여지를 보존합니다.

🤖 Generated with Claude Code

ENvironmentSet and others added 9 commits July 8, 2026 18:10
The JSDoc, changeset, and runtime (`?? null`) all promise that returning
`undefined` means "nothing to provide", but the declared return type
`StackSnapshot | null` rejected a bare `return;`. Widen it to
`StackSnapshot | null | void`, following the sibling hook `onLoadError`'s
existing `| void` precedent (including the biome-ignore rationale), and
drop the forced cast the old type required in provideSnapshot.spec.ts.

Runtime behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…often additive claim (C1#9, C2#5)

- `provideSnapshot?(({ initialContext }))` / `onLoadError?(({ error, ... }))`
  double-parenthesis typos — these render verbatim in the CHANGELOG.
- `SnapshotLoadError`'s cause is a discriminated object, not a string enum:
  describe it as `cause.kind` so release-note readers don't write
  `error.cause === "invalid-events"` (always false).
- "entirely additive" overstated the change: `StackflowActions.captureSnapshot`
  and `onInit`'s `initializedBy` are required members, which is compile-breaking
  for mock constructors and wrap-and-forward hook callers. State the claim as
  runtime-additive with a type-level caution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w; make persister mimic self-handle decode failure (C1#4)

A throwing hook is undefined behavior today: the error propagates raw out
of makeCoreStore and onLoadError is never involved. Two characterization
tests pin that propagation so any future change to it is a conscious
contract decision.

The persister mimic previously demonstrated the fragile pattern (raw
JSON.parse inside provideSnapshot — a truncated storage write would crash
boot). As the reference implementation persister authors will copy, it now
self-handles decode failure: discard the stored value and return null so
creation falls back to the create path. A third round-trip test exercises
that branch end-to-end. Existing assertions are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1#10)

The recover contract is that a { recover: "create" } return resumes the
create path from its start — not merely that some stack comes out. Existing
recover tests only asserted the resulting activity list, so a regression
that skipped the overrideInitialEvents chain or the initial-activity
handlers on the recovery path would have passed unnoticed.

Pin the coexistence case of a failing provider and an
overrideInitialEvents plugin: the chain runs over the option's initial
events, its substitution is what the stack is built from, the
initial-activity handler judges that substitution, and onInit reports
initializedBy "create".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uplicate-id last-wins, unknown-property tolerance (C1-nit-3, C2#7)

Three edges of the load contract existed only as unstated behavior:

- onLoadError returning a truthy value other than the exact
  { recover: "create" } decision rethrows the SnapshotLoadError — the
  recovery check must never loosen into truthiness.
- A consumer-transformed snapshot with duplicate event ids is accepted,
  and dedup keeps the last occurrence (the earlier event is dropped).
  Note the direction: last-wins, the opposite of what C2#7's prose
  described.
- Unknown properties — both on the snapshot object and on individual
  event items — are tolerated (forward compatibility), so the structure
  check isn't tightened by accident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-load navigation was only tested for push, leaving half of the
homogeneity postcondition unpinned: a rebase regression that broke
pop/step targeting would have passed the suite. Both interact directly
with rebased dates — pop exits the latest activity and StepPushed resolves
its target as the latest active activity by eventDate.

Pin both on a restored two-activity stack: pop starts the exit transition
on the restored top and re-exposes the restored activity below as active;
stepPush lands its step on the restored top, not the activity underneath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…C2#4)

The capture contract promises that replaying a pause-time snapshot yields
the state as if the pause never happened — queued navigation fully
applied. Only the capture half was tested (the queued event is included in
the snapshot); nothing verified that loading such a snapshot actually
materializes the queued activity.

Close the round trip: pause, push (queued, not yet visible), capture, then
load into a fresh store — the queued activity comes out settled
(enter-done, on top) and the restored stack is idle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three comments restated what the code (or a type) already says and would
drift as the code changes:

- loadSnapshot.ts header enumerated the four checks and their error
  mapping — that truth lives in the SnapshotLoadError cause type and the
  function body. The WHY that stays: static info is re-derived from the
  current config, never the snapshot.
- assertSnapshotStructure's JSDoc walked through its own checks; keep only
  why the check exists (fail loudly before replay, not fold into a
  corrupt stack).
- createStack's JSDoc narrated chain/handlers/aggregate steps; keep only
  the guarantee it encodes (no provider → observably identical to before).

Design-doc marker references (WHY pointers) are intentionally kept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (C1#5, C2#1)

The rebase used a fixed 1ms integer spacing that never looked at static
event dates. The react integration backdates static events by only
2×transitionDuration, so a snapshot with more events than
transitionDuration ms (350 by default) had its earliest events folding
before Initialized/ActivityRegistered — the design's general placement
rule (inside the window after the static events, fractional dates fitting
any count) existed only on paper, while the code comment claimed RB1–RB5.

Give the rebase the latest static event date as the window's exclusive
lower bound and space events fractionally: min(1, window/(N+1)) ms keeps
any history length inside the window, and a roomy window degrades to the
previous 1ms spacing. A degenerate window (static events dated at or past
the settled bound) falls back to settledness alone, as the design's
exception path argues is safe.

Pin the placement with a 400-event snapshot against react-style static
events: every rebased date lands after every static event, at or before
creation − transitionDuration, strictly increasing, and the replay
settles. The pin fails on the previous fixed-spacing implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

종합 리뷰 코멘트에 대한 답변입니다.

꼼꼼한 리뷰 감사합니다. finding별 처분 결과입니다 — 수용 항목의 커밋은 이 브랜치에 반영되어 있습니다. 조치 완료 상태에서 yarn typecheck·yarn build·yarn test(core 22 스위트 148 테스트) 전부 green을 확인했습니다.

처분 요약

finding 처분 커밋
#1 구조 검사 payload 심화 (하위 a·b 포함) 기각 — 스코프/비용-효용 결정
#2 루트 문서 큐레이션 기각 — 의도된 배치
#3 로드 검증 경계가 onInit 이전에 끝남 무조치 — 코어 결함 아님 (상세 참조)
#4 훅 throw 핀 테스트 + 미믹 취약 패턴 수용 86351e2
#5 RB3 창 배치 미구현 수용 — 구현을 설계에 정렬 6bb72db
#6 스냅샷 값 의미론 JSDoc 기각 — 문서화는 이번 PR 스코프 외
#7 이벤트 id 제2 정렬 채널 타당 — 별도 선행 PR로 이연
#8 provideSnapshot 반환 타입 수용 fc9fbce
#9 체인지셋 문구·오타 수용 66705fe
#10 recover:create 파이프라인 미검증 수용 ac5c7ba
#11 주석 정리 + 마커 스윕 부분 수용 — WHY-only 정리만 ab22fe4
#12 문서 사이트 미커버 기각 — 유예 결정의 기록은 이 답글
#13 복원 액티비티 loaderData 부재 무조치 — plugin-loader 동반 갱신으로 처리 예정
nit-1 PR 본문 문구 수용 — PR 본문 수정
nit-2 에러 타입 비일관 기각 — 설계 §3.3의 명문 의도
nit-3 미고정 계약 엣지 3건 수용 2268a24
nit-4 커밋 이력 squash-merge로 해소 예정 (지적 제안 그대로)
nit-5 stack-depth-change initializedBy 후속 — 확장 다음 손볼 때 optional 필드로 (지적 제안 그대로)
기각후보 3건 기각 판단에 동의 — 근거 재확인 완료

상세

#1 — 기각 (스코프/비용-효용 결정). 재현된 실패 모드(payload 결손 이벤트가 4개 검사를 통과해 id: undefined 활성 액티비티가 만들어지고 onLoadError가 안 불림)는 사실로 확인했습니다. 그럼에도 조치하지 않기로 결정했습니다. 지금 stackflow 구조에서 이벤트 이름별 payload 형태 검증까지 들어가면 검사기가 과도하게 복잡해지는 데 비해, 이 시나리오는 "유효 JSON + 유효 $schema + 유효 이벤트명 + string id인데 payload 필드만 결손"이라는 좁은 회랑의 극단 엣지케이스라 비용 대비 효용이 없다시피 하다고 판단했습니다. 구현은 설계 §3.4가 명세한 incompatible-schema 검출 범위("$schema 불일치 · events 비배열 · 항목이 6종 아님 · id/name 결손")와 1:1 정합하므로, 검사 심화는 §3.4 개정을 수반하는 설계 결정 사안이고 그 개정을 채택하지 않은 것입니다. 흔한 손상 형태(잘린 JSON, quota eviction)는 #4 조치가 커버합니다. 하위 (a) 중복 id: 거부 대신 현행 last-wins 경계를 테스트로 고정했습니다(nit-3, 2268a24). 하위 (b): activityName 누락은 등록 검사가 invalid-events로 잡으므로 onLoadError 라우팅 자체는 유지됩니다 — 분류 재조정도 같은 검사 범위 결정에 속해 채택하지 않습니다.

#2 — 기각. 루트의 plan 문서 2개·설계 문서·CONTEXT.md 배치는 의도된 결정입니다(지적하신 파일·행 위치의 사실관계는 확인했습니다). 삭제·이동하지 않습니다.

#3 — 무조치. core의 load는 계약대로 성공했습니다 — 재현하신 크래시는 아직 load path 지원이 준비되지 않은 현행 plugin-history-sync가 자기 onInit에서 던진 것입니다. 이 core 변경은 major 릴리즈로 나갈 예정이고, plugin-history-sync도 그에 맞춰 load path를 지원하도록 함께 업데이트할 계획입니다. 따라서 설계 문서에 한계를 명시하거나 FEP-2546에 등재하는 조치는 하지 않습니다.

#4 — 수용 (86351e2).provideSnapshot throw의 raw 전파, ② onLoadError throw의 raw 전파를 핀하는 특성화 테스트 2건을 추가했습니다(계약 승격이 아니라 현재 동작의 회귀 감지 장치 — 테스트 주석에 명시). ③ persisterRoundtrip 미믹의 provideSnapshot을 decode 실패 자체 처리(try/catch → 보존물 폐기 → null 반환) 모양으로 고치고, 잘린 write에서 create로 기동하는 왕복 테스트로 그 분기를 실증했습니다. 기존 단언은 전부 불변입니다.

#5 — 수용 (6bb72db). 지적대로 구현이 RB3 일반 규칙(정적 이벤트 뒤 창 안 배치)을 구현하지 않은 채 주석만 "RB1–RB5"를 주장하고 있었습니다. rebase가 정적 이벤트 max date를 창의 배타 하한으로 받아 min(1, 창/(N+1)) ms 소수 간격으로 창 안에 배치하도록 수정했습니다 — 설계 문서는 무변경, 구현을 설계에 맞췄습니다. 지적하신 "핀 테스트 부재"도 해소: 400 이벤트 + react식 2×transitionDuration 백데이트 정적 이벤트로 창 안 배치를 검증하는 테스트를 추가했고, 이 테스트가 구 구현에서 실패함을 확인했습니다. 퇴화 창은 설계의 예외 논증대로 정착 상한(RB2)만 유지합니다.

#6 — 기각 (스코프 결정). 동작 자체는 as-designed임을 확인했고(설계 §3.2가 "복사 후 변환, 직접 변이는 미정의 동작"을 명문화), shipped 표면(JSDoc)으로의 전사는 문서화 작업으로서 이번 PR 스코프가 아닙니다.

#7 — 타당, 별도 선행 PR로 이연. activity 정렬이 id에 의존하는 것 자체가 문제라는 지적에 동의합니다 — id에 순서(order)가 부여되어 있으면 안 됩니다. 증상 방어(id 단조 검증 추가)가 아니라, activity 정렬이 id 대신 eventDate나 z-index 같은 더 적절한 값을 쓰도록 바꾸는 별도 PR을 이 PR에 선행해서 만들어 구조적으로 해결하겠습니다. 이번 PR에서는 조치하지 않습니다.

#8 — 수용 (fc9fbce). StackSnapshot | null | void로 확장했습니다 — 자매 훅 onLoadError| void 선례(biome-ignore 포함) 그대로이고, 설계 §3.3 산문("null(또는 undefined) = 공급할 것 없음")과 코드의 정합화입니다. 스펙의 강제 캐스트도 제거했습니다. 런타임 무변경.

#9 — 수용 (66705fe). 이중 괄호 오타 2곳 수정, cause.kind 표기로 수정, "entirely additive"를 runtime-additive로 완화하고 타입 레벨 주의(mock 생성자·훅 인자 구성자는 신규 필수 멤버 추가 필요)를 한 문장 추가했습니다.

#10 — 수용 (ac5c7ba). 실패 공급자 + overrideInitialEvents 플러그인 공존 테스트를 추가했습니다 — recover 후 체인이 options의 초기 이벤트 위에서 호출되고, 치환 결과가 스택에 반영되며, initial-activity 핸들러가 그 치환을 판정하고, initializedBy: "create"임을 단언합니다.

#11 — 부분 수용 (ab22fe4). 코드 재서술 주석 3곳(loadSnapshot.ts 헤더의 4-검사 열거, assertSnapshotStructure JSDoc, createStack JSDoc)을 WHY만 남기고 정리했습니다. 마커 스윕 반쪽은 전제 소멸 — #2 기각으로 설계 문서·CONTEXT.md가 루트에 남아 마커 참조 대상이 유지됩니다.

#12 — 기각 (유예 결정 기록). 문서 사이트·CLAUDE.md 갱신은 이번 PR 스코프가 아닙니다. finding이 요구한 "명시적 결정 기록"은 이 답글이 그 기록입니다 — 새 공개 표면의 문서 사이트 반영은 FEP-2546과 함께 다룹니다.

#13 — 무조치. 복원 액티비티의 loaderData 재주입은 core 계약의 문제가 아니라 소비자(plugin-loader)의 책임이며, major 릴리즈에서 plugin-loader를 함께 업데이트해 다룰 계획입니다. §7.1.3의 승격은 하지 않습니다.

nit-1 — 수용. PR 본문의 해당 문구를 "polled synchronously at store creation (in React, inside <Stack/>'s first render — including SSR renders)" 취지로 수정합니다.

nit-2 — 기각. 의도된 구분이고 설계 §3.3에 명문입니다: 다중 프로바이더 충돌은 특정 스냅샷의 결함이 아니라 배선 버그라 SnapshotLoadError가 아니고 onLoadError로 라우팅되지 않습니다("1차 처리자 = 공급자"를 적용할 단일 공급자가 정의되지 않기 때문). 잔여 조치는 shipped 표면 문서화인데 이번 PR 스코프 외입니다.

nit-3 — 수용 (2268a24). 세 엣지 전부 핀했습니다: {recover:"retry"} 같은 truthy 비-create 반환은 rethrow / 중복 id 스냅샷은 last-wins(늦은 항목 생존 — 앞 이벤트 드랍) / 스냅샷·이벤트 항목의 미지 프로퍼티 수용(전방 호환).

nit-4 / nit-5 / 기각후보 3건 — 제안 그대로 따릅니다: 커밋 이력은 squash-merge(리라이트 없이 큐레이션 커밋을 위에 쌓음 — 이번 조치 커밋들이 그 방식입니다), stack-depth-change의 initializedBy 전달은 확장을 다음에 손볼 때 optional 필드로 추가하는 후속으로 둡니다. 스스로 기각하신 후보 3건은 그 기각 근거가 타당함을 재확인했습니다(load의 overrideInitialEvents 스킵은 R6의 의도된 구조, 비-Pushed initialEvents 임베딩은 설계 §4.2-6의 문서화된 전제 밖, NavigationEvent는 history-sync 공개 표면과 충돌 없음).

@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

설계 충실성·요구사항 검증 코멘트에 대한 답변입니다.

설계 충실성·요구사항 검증 감사합니다. finding별 처분 결과입니다 — 수용 항목의 커밋은 이 브랜치에 반영되어 있고, 조치 완료 상태에서 yarn typecheck·yarn build·yarn test(core 22 스위트 148 테스트) 전부 green을 확인했습니다.

처분 요약

finding 처분 커밋
1 RB3 일반 케이스 창 배치 미구현 수용 — 구현을 설계에 정렬 6bb72db
2 구조 검사 깊이 (payload 결손 통과) 기각 — 스코프/비용-효용 결정
3 테스트 공백: load 후 pop/step 수용 62cef36
4 테스트 공백: pause 캡처의 load 사후조건 수용 382a209
5 changeset의 cause 문자열식 서술 수용 66705fe
6 N1 미세 차이: initialContext 공유 객체화 기록 접수 — 무조치
7 중복 id 경계 미고정 수용 + 방향 서술 정정 2268a24
open question 1 (훅 throw 미정의) 동의 확인 — 계약 무변경, 특성화 테스트만 추가 86351e2
open question 2 (empty-navigation 예시) 동의 확인 — 조치 없음
기타: 마커 주석 sweep 전제 소멸 — 문서 루트 잔류 결정

상세

1 — 수용 (6bb72db). 권고하신 두 방향 중 전자(소수 간격으로 창 안에 채우기)를 택했습니다 — 설계 문서는 무변경이고 구현을 RB3에 맞췄습니다. rebase가 정적 이벤트 max date를 창의 배타 하한으로 받아 min(1, 창/(N+1)) ms 소수 간격으로 배치합니다(넉넉한 창에서는 종전과 같은 1ms 간격으로 퇴화해 행동 변화 최소). 창 안 배치를 핀하는 테스트(400 이벤트 + react식 2×transitionDuration 백데이트 정적 이벤트)를 추가했고, 구 구현에서 실패함을 확인했습니다. 퇴화 창은 설계의 예외 논증대로 정착 상한만 유지합니다.

2 — 기각 (스코프/비용-효용 결정). "설계 §3.4의 검사 범위 명세에는 충실하므로 구현 잘못은 아니"라는 판단 그대로입니다 — 검사 심화는 §3.4의 검사 범위 개정을 수반하는 설계 결정인데, 지금 stackflow 구조에서 payload 수준 검증까지 들어가는 복잡도 대비 이 시나리오("유효 JSON + 유효 $schema + 유효 이벤트명 + string id인데 payload만 결손")는 좁은 회랑의 극단 엣지라 비용 대비 효용이 없다고 판단해 채택하지 않습니다. R4 정신과의 긴장은 사실로 인지하고 있습니다. 흔한 손상 형태(잘린 JSON 등)는 참조 구현(persister 미믹)의 decode 자체 처리로 커버합니다(86351e2).

3 — 수용 (62cef36). 복원 2-스택 위에서 pop(복원 최상단 exit 전환 + 아래 복원 activity 재노출·활성화)과 stepPush(eventDate 기반 최신 활성 activity 타깃이 복원 최상단에 적중)를 핀하는 테스트 2건을 추가했습니다 — 지적하신 대로 둘 다 재기저 date와 직접 상호작용하는 지점입니다.

4 — 수용 (382a209). pause 중 push → 캡처(큐잉 이벤트가 가시 activity가 아님을 전제로 확인) → 새 스토어 load까지 왕복을 닫는 테스트를 추가했습니다 — 큐잉됐던 activity가 enter-done 최상단으로 물화되고 복원 스택이 idle임을 단언합니다. §3.2 계약("pause가 없었던 것처럼")의 load 측 절반이 이제 고정됩니다.

5 — 수용 (66705fe). cause.kind 표기로 수정했습니다(같은 커밋에서 훅 시그니처 이중 괄호 오타와 "entirely additive" 과장 문구도 정정).

6 — 기록 접수 (무조치). 실측으로 확인했습니다 — main은 options.initialContext ?? {}를 호출마다 평가, 현행은 공유 const. 지적대로 N1 자구에 걸리는 유일한 지점으로 기록해 둡니다. args.initialContext를 변이하는 플러그인의 실증 사례가 레포·생태계에 없어 조치는 하지 않되, 필요해지면 호출부 인라인 1줄로 원복 가능합니다.

7 — 수용 + 방향 서술 정정 (2268a24). 의도된 경계가 맞고, 테스트 한 줄로 고정하자는 제안을 수용했습니다. 다만 방향 서술을 정정합니다: uniqBy(utils/uniqBy.ts)는 reverse→filter→reverse 구조라 마지막 출현을 보존합니다 — 조용히 버려지는 것은 항목입니다(last-wins). 핀 테스트의 기대값도 last-wins(늦은 항목 생존)로 작성했습니다.

open question 1 — "미정의로 두는 것에 동의" 확인했습니다. 계약은 승격하지 않고, 현재의 raw 전파 동작을 특성화 테스트 2건으로만 핀해 회귀 감지 장치를 두었습니다(86351e2 — 테스트 주석에 특성화 취지 명시). open question 2 — 설계 수정 불요 판단 동의 확인, 조치 없습니다.

기타 — 마커 주석 sweep은 전제가 소멸했습니다: 설계 문서·CONTEXT.md의 루트 배치는 의도된 결정으로 유지되어, 코드 주석의 §마커 참조 대상이 그대로 남습니다. 스펙 품질 평가(재생 결과 기준 계약 고정·compaction 진화 여지 보존)는 감사히 받았습니다 — 이번에 추가한 테스트들도 같은 기준(재기저 date 구체값이 아니라 창 경계·재생 결과)으로 작성했습니다.

Comment thread core/src/makeCoreStore.ts Outdated
ENvironmentSet and others added 2 commits July 9, 2026 17:35
…napshot→events→stack pipeline (review: error-kind rename)

The three kinds misnamed what they detect. `incompatible-schema`
suggested a version incompatibility, but the check is a catch-all for
any unrecognizable snapshot shape ($schema mismatch, non-array events,
an item that is not a navigation event) — now `unrecognized-snapshot`.
`invalid-events` suggested a defect intrinsic to the events, but the
failure is relational — the events are fine, they just don't fit the
current config (e.g. they materialize an unregistered activity) — now
`incompatible-events`. `empty-navigation` describes a replay that
settles with zero enter-state activities, i.e. an empty stack — now
`empty-stack`.

Alongside the rename:
- `unrecognized-snapshot` gains a required `detail: string` naming which
  structural check failed (its sibling `incompatible-events` already
  carried one); the item check includes the offending index. It is a
  `string` (not `unknown`) because core authors the message itself,
  whereas `incompatible-events` wraps arbitrary thrown values.
- The cause JSDoc states `empty-stack`'s precise condition: zero
  enter-state activities, not an empty `activities` array — exit-done
  activities may remain.

All three kinds are public exports but unreleased, so the rename is
free now and breaking later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…record (review: initInfo record)

The one-shot create/load signal was a bare string
(initializedBy: "create" | "load"). Promote it to a discriminated
record, initInfo: { kind: "create" | "load" }, because the two shapes
age differently: promoting a string to a record later is a breaking
change to the hook signature, while adding fields to an existing record
is additive. Per-path payloads are already latent — "create" is set
both on plain creation and on recover-after-load-failure, which a
consumer cannot currently tell apart but a record could expose
additively (e.g. { kind: "create", recoveredFrom }), and the load side
has room for provenance (e.g. { kind: "load", providedBy }). The
discriminant is named kind, matching SnapshotLoadErrorCause's
discriminated union.

The signal is unreleased, so the rename is free now and breaking later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… an initInfo signal (review: load interception)

Activities restored from a snapshot never met an interception point:
the replay bypassed both overrideInitialEvents (skipped on load) and
onBeforePush (replay is not the action pipeline), so a consumer like
plugin-loader could not attach loaderData to restored activities —
"an entered activity runs its loader" broke exactly on the load path.

Run the plugins' overrideInitialEvents chain on load too:

- The hook signature widens to NavigationEvent[] in and out and gains
  initInfo: { kind: "create" | "load" } — the same one-shot record
  onInit receives (StackInitInfo, now a shared exported type). On load
  the chain receives the snapshot's full replay sequence and its
  return is adopted as the replay sequence.
- The chain runs after the structure check (hooks never see an
  unrecognizable value) and before everything else, so the
  registration check, rebase, replay, and enter-state postcondition
  all apply to the sequence that actually replays. A reshaped return
  that fails validation surfaces as a SnapshotLoadError routed to the
  snapshot provider; a throwing hook is a plugin bug, not a snapshot
  defect, and propagates raw.
- Load non-interception is no longer structural — it is each
  consumer's responsibility. A plugin with no load policy must return
  initialEvents unchanged, and plugins that fabricate initial events
  (history-sync's URL interpretation) or write activityContext from
  creation-scoped input (plugin-loader's initialLoaderData branch)
  must guard on initInfo.kind === "load" in their own packages before
  any snapshot provider ships alongside them — required follow-ups
  within the same unreleased window as this mechanism.
- MakeCoreStoreOptions' onInitialActivityIgnored handler parameter
  widens with the chain's return type, and the history-sync spec's
  hand-built hook-argument call sites add the now-required initInfo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

rebaseNavigationEvents: static까지 함께 rebase하면 window 계산을 통째로 걷어낼 수 있습니다

loadSnapshot.ts L149-168 의 rebase가 latestStaticEventDate~settledUpperBound 사이의 window, 분수 spacing, window > 0 ? … : 1 퇴화 폴백까지 끌고 오는 유일한 이유는 "static event의 eventDate는 건드리지 않는다" 는 자가 제약 하나 때문입니다.

그런데 static event(Initialized/ActivityRegistered)는 navigation-inert 합니다:

  • aggregateeventDate정렬 키로만 사용 (aggregate.ts:11-12) — activity를 만들지도, transitionState를 갖지도 않음
  • transitionDurationInitializedpayload 필드지 그 이벤트의 날짜가 아님 (makeStackReducer.ts:93)
  • captureSnapshot이 재캡처 시 filter(isNavigationEvent)로 static을 제외 → static 날짜는 애초에 스냅샷으로 영속화되지 않음

즉 static은 "동적 이벤트보다 앞" 이라는 상대 순서만 지키면 됩니다. 그러면 [...staticEvents, ...navigationEvents] 를 concat해 settledUpperBound 에서 1씩 거꾸로 재기저하는 단일 규칙으로 충분하고, latestStaticEventDate reduce / window / 분수 spacing / 퇴화 폴백을 전부 삭제할 수 있습니다.

transitionDuration: 0 팀은 지금 이 퇴화 폴백이 예외가 아니라 기본 경로 입니다

window = (now − td) − latestStaticEventDate 인데, react 통합에서 static은 enoughPastTime() = now − 2·td 로 찍힙니다 (stackflow.tsx:91). 정리하면:

window = (now − td) − (T_생성 − 2·td) = (now − T_생성) + td = δ + td

td = 0 이면 window = δ(생성 시점과 loadSnapshot 시점 사이의 간격)인데, 둘은 같은 동기 makeCoreStore 흐름 안이라 대부분 sub-ms → δ = 0 → window = 0 → else 폴백이 매 로드마다 발동합니다.

이 폴백 가지는 settledUpperBound − k 로만 배치해 latestStaticEventDate 하한을 무시하므로, 재기저된 nav 이벤트가 static 이벤트보다 앞으로 정렬됩니다. 지금 이게 깨지지 않는 건 오직 reducer가 이벤트 순서에 관대하기 때문입니다(Pushed 핸들러가 등록 여부를 안 보고 — makeStackReducer.ts:153, validateEvents도 순서 무관 — 이름 필터만). 즉 window가 지키려던 "nav after static" 불변식은 td=0 팀에게선 이미 깨져 있고, 우연히 살아남는 중입니다. L159-161 주석의 "a direct core embedding" 은 희귀 케이스가 아니라 td=0 설정의 상시 경로입니다.

static까지 함께 rebase하면 static-before-nav가 td=0/td=350 모두에서 구조적으로 보장되고, 두 경로가 하나로 합쳐집니다.

후속

rebase.spec.ts"load - 이벤트 수가 transitionDuration(ms)을 넘어도 재기저 date가 정적 이벤트 뒤의 창 안에 배치됩니다" 테스트는 window 전제 자체를 검증하므로, 이 방향으로 가면 함께 재작성돼야 합니다.

@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

captureSnapshot: "이벤트 자신의 전이가 정착(settled)한" navigation만 담아야 합니다

현재 captureSnapshot(makeCoreStore.ts:191-205)은 원본 로그의 navigation 이벤트를 전부 담습니다. 그런데 load 경로는 effect-silent입니다 — load 성공 시 stackValue = loaded.stack직접 대입만 하고 setStackValue(→ produceEffects → post-effect 훅)를 타지 않습니다(makeCoreStore.ts:153-155). 복원 이벤트는 상태만 재구성될 뿐 PUSHED/POPPED/onChanged 등 어떤 effect도 재생되지 않습니다.

문제는 전이가 아직 정착되지 않은 이벤트를 담을 때입니다. produceEffects에서 enter-active→enter-done 정착 틱은 %SOMETHING_CHANGED%onChanged 를 냅니다. 즉 done 전이에 붙는 side-effect가 실재합니다. 미정착 이벤트를 그대로 capture → load 하면:

  • live 세션: transitionDuration이 안 지나 done side-effect가 아직 안 남
  • load: effect-silent이라 그 effect가 영원히 안 남
  • 그런데 rebase는 상태를 done으로 밀어버림

effect는 누락됐는데 상태만 done인 불일치가 생깁니다. 특히 두 부류가 여기 걸립니다:

  1. pause 중 큐잉되어 끝내 resume되지 않은 이벤트. dispatchEvent는 pause와 무관하게 항상 events.value.push(makeCoreStore.ts:214) 하지만, reducer는 이를 pausedEvents로 우회시켜 aggregate에 반영조차 하지 않습니다(makeStackReducer.ts:14-29). capture는 원본 로그를 읽으니 이 큐잉분을 담고, 스냅샷엔 Paused가 실리지 않으므로(navigation 아님) load 때 그냥 적용됩니다. live에서 effect가 전혀 없던 pending 이벤트가 리로드하면 done activity로 튀어나옵니다.
  2. 자신의 전이가 아직 active인 이벤트(enter-active / exit-active).

계약: 스냅샷 = "마지막으로 커밋된 navigation". 따라서 capture 술어를 "이벤트 자신의 전이가 정착(done)된 것만" 으로 잡읍시다.

술어는 액티비티 단위가 아니라 per-event 여야 합니다

push A(정착) → push B(정착, enter-done) → pop B(exit-active, 캡처 시점)

B를 가리키는 이벤트는 Pushed B · Popped B 입니다. "액티비티 B가 active" 기준으로 거르면 둘 다 빠져 B가 통째로 사라집니다. 올바른 결과는 정착된 Pushed B는 남기고 미정착 Popped B만 빼서 — pop만 취소되고 B는 enter-done으로 보존 — 입니다. 그래서 탈락 시맨틱이 방향마다 다릅니다:

  • 미정착 Pushed 탈락 → 액티비티 제거 (아직 진입 전)
  • 미정착 Popped / StepPopped 탈락 → 직전 정착 상태 보존 (pop 미완)

enter 케이스(push A → push B(enter-active))는 B를 가리키는 이벤트가 Pushed B 하나라 per-event와 per-activity가 우연히 일치하지만, pop/step-pop/replace는 정착된 과거 이벤트와 미정착 최신 이벤트가 같은 타깃을 공유하므로 반드시 per-event로 판정해야 합니다.

결합

per-event 정착 판정은 각 이벤트가 만든 element의 transitionState를 알아야 하므로, 지금 원본 로그만 읽는 captureSnapshot이 aggregate된 상태에 결합됩니다. 의도된 방향입니다.

@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

등록 검사: loadSnapshot 전용 검사를 없애고 validateEvents로 통일합시다

loadSnapshotPushed/Replaced의 activity 등록 여부를 자체 검사합니다(loadSnapshot.ts:38-53). 주석(:38-42)이 그 이유를 "validateEventsPushed만 보는데 Replaced도 이름으로 activity를 materialize하므로 여기서 추가로 본다"로 설명합니다. 그런데 이 검사는 validateEvents로 옮기고 통째로 지우는 편이 낫습니다.

전용 검사는 이미 중복입니다

  • aggregate는 내부에서 validateEvents(events)를 호출하고(aggregate.ts:19),
  • loadSnapshot은 그 aggregate를 try/catch로 감싸 어떤 에러든 SnapshotLoadError({ kind: "incompatible-events" })로 변환합니다(loadSnapshot.ts:71-80).

즉 미등록 Pushed는 이미 aggregate → validateEvents(throw) → wrap 으로 incompatible-events가 됩니다 — 전용 검사의 Pushed 가지는 지금도 중복입니다. 여기에 ReplacedvalidateEvents로 넣으면 Replaced 가지도 중복이 되어, loadSnapshot.ts:38-53 블록 전체를 삭제할 수 있습니다.

rebase는 eventDate만 바꾸고 이름/등록엔 무관하며 validateEvents는 순서 독립(filterEvents 기반)이라, "pre-rebase 전용 검사 → post-rebase aggregate 검사"로 옮겨도 결과가 같고, 에러 kind도 양쪽 다 incompatible-events라 관측 동작이 변하지 않습니다.

validateEvents의 비대칭을 소스에서 바로잡는 것이기도 합니다

validateEventsPushed는 검사하고 Replaced는 안 보는 것 자체가 원래 비대칭입니다 — 둘 다 "이름으로 activity를 materialize"하는데 말이죠. Replaced 등록 검사는 원래 있었어야 할 검사이므로, validateEvents에 추가하는 걸 core 계약 변경이 아니라 누락 보정으로 봅니다(config-first에선 replace가 등록된 이름만 타깃하므로 live 경로에서 실질 발화도 없음).

제안: validateEventsReplaced 등록 검사 추가 → loadSnapshot의 전용 등록 검사 블록(:38-53) 삭제.

@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

SnapshotLoadErrorObject.setPrototypeOf 는 불필요합니다

SnapshotLoadError.ts:36-38Object.setPrototypeOf(this, SnapshotLoadError.prototype)(+주석)는 class extends ErrorES5로 down-level 트랜스파일할 때 깨지는 프로토타입 체인을 복구하는 workaround입니다. 그런데 이 패키지는 그 정도로 낮은 target을 대상으로 하지 않습니다:

  • JS 빌드(esbuild) target = es2015 (packages/esbuild-config/index.js:11)
  • 타입(tsc) target = ESNext (tsconfig.json:3)

es2015 target에선 esbuild가 네이티브 class 를 그대로 방출하고, 네이티브 extends Error는 프로토타입 체인을 유지하므로 instanceof가 별도 조치 없이 동작합니다. 즉 setPrototypeOf 없이도 makeCoreStore.ts:157,169error instanceof SnapshotLoadError 분기는 정상입니다. 체인 붕괴는 ES5 에뮬레이션 특유의 문제라, es2015+ 를 나가는 이 빌드에선 죽은 방어 코드입니다.

→ 라인 36-38(코드 + 주석) 삭제 권합니다.

ENvironmentSet and others added 7 commits July 9, 2026 23:40
…adError (review: dead prototype workaround)

Object.setPrototypeOf(this, SnapshotLoadError.prototype) restores the
prototype chain that breaks only when `class extends Error` is
down-level transpiled to ES5. This package never targets that low: the
JS build (esbuild) is es2015 and the type build (tsc) is ESNext, both of
which emit a native `class` whose native `extends Error` keeps the
prototype chain — so `instanceof SnapshotLoadError` (makeCoreStore's load
recovery branches) already holds without it. The workaround guards
against an emission mode this build never produces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(review: unify registration check)

loadSnapshot ran its own Pushed/Replaced registration check before
replay. The Pushed half was already redundant — aggregate calls
validateEvents, whose throw loadSnapshot wraps into
incompatible-events — and validateEvents only omitted Replaced, though
Replaced materializes an activity by name exactly as Pushed does.

Add the Replaced arm to validateEvents (correcting that asymmetry at the
source) and delete loadSnapshot's dedicated block. rebase touches only
eventDate, and validateEvents is order-independent, so moving the check
from pre-rebase to the post-rebase aggregate leaves the result identical;
both routes surface as incompatible-events, so the observable failure is
unchanged. The Replaced check is a missing-check correction, not a
contract change: config-first replace only targets registered names, so
it does not fire on the live path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rop the window math (review: rebase static together)

The load rebase carried a latestStaticEventDate reduce, a static→settled
window, fractional spacing, and a degenerate-window fallback for one
reason: the self-imposed rule that static events (Initialized,
ActivityRegistered) keep their original eventDate. But static events are
navigation-inert — aggregate reads their eventDate only as a sort key,
transitionDuration is a payload field rather than that event's date, and
captureSnapshot never persists static events — so they need only stay
ordered before the navigation events.

Re-date static and navigation events in a single backward walk from
now − transitionDuration, one step per event, and the window machinery
falls away. Static-before-navigation becomes structural at any history
length. It also fixes a latent td=0 defect: the react integration
backdates static by 2·td, so at td=0 static and freshly re-dated
navigation collided and the degenerate fallback sorted navigation ahead
of static — surviving only because the reducer tolerates event order.
The shared re-dating removes that reliance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ew: per-event settled capture)

captureSnapshot took every navigation event from the raw log, including
events whose own transition had not settled. Load is effect-silent — it
assigns the reconstructed stack directly, replaying no PUSHED/onChanged
effect — and the rebase drives every restored event to done, so a
mid-transition or pause-queued event, once captured, reappeared on
reload as a settled state the live session never committed and never
fired its done-effect for. A push queued behind a pause that never
resumed came back as a done activity that was never shown.

Re-aggregate the log at capture time (flooring now at the latest event
date, as dispatchEvent does, so a just-committed event reads as settled)
and drop any event whose transition is still in flight: the entering
event of an enter-active activity (its activity has not committed) and
the exiting event of an exit-active one (its last committed state is
preserved), plus everything queued in an unresumed pause. The predicate
is per-event, not per-activity — a settled Pushed and an unsettled
Popped can share a target, and only the Popped drops.

This reverses two capture behaviors: a pause-queued push is no longer
carried into the snapshot (previously it re-materialized on load), and a
mid-transition event is no longer captured until it commits. The
snapshot now equals the last committed navigation history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… not just its entering event (review B1: capture dependency closure)

The committed-navigation capture predicate collected uncommitted events
per element — the entering event of an enter-active activity, the exiting
event of an exit-active one, and pause-queued events. But a step event
settles the instant it applies (Step* reducers have no transition gate),
so a step dispatched onto an activity still mid-enter stayed "committed"
and survived into the snapshot even as its parent's Pushed was dropped.
On reload that step is an orphan: core's stepPush leaves targetActivityId
unset, so findTargetActivityIndices falls back to the latest active
activity and grafts the step onto a different, committed activity below —
silent corruption, no error. A persister capturing on every onChanged
reaches this through any step taken inside an enter transition.

Bind the drop to the activity, not the element. Fold the log once (as
aggregate does) and attribute every event to the activity it enters
(Pushed/Replaced) or targets (Popped/Step*), resolving step and pop
targets against the running stack the same way the reducer does — so a
step's parent is known even after the step is superseded. An event is
kept only if its activity committed (entry settled), which drops an
uncommitted activity's entire span — its steps, surviving or superseded,
included — alongside the exit-in-flight Popped and pause-queued events
already handled. No step outlives its parent, so no orphan can graft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review A1: validateEvents Replaced note)

Record in the changeset that folding the snapshot load's registration
check into validateEvents also makes aggregate reject an unregistered
Replaced on every path, matching its existing Pushed check — dormant in
config-first usage where a replace only targets registered activities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…resumed-paused (review: capture records, not settlement)

An earlier revision restricted captureSnapshot to committed (settled)
navigation, excluding mid-transition events and adding activity-level
attribution so a dropped activity's steps did not orphan. Revisit the
model: a transition is only a visual effect, and a navigation event's
effect hook (onPushed/onPopped) already fires in the active state — so
reconstructing an unsettled event as state on load loses no effect and
produces no state/effect mismatch. What makes an event part of the
history is that it entered the aggregate, not that its transition
settled.

Capture every navigation event the log records, excluding only events
queued behind a pause that never resumed — those are quarantined out of
the aggregate (never applied), so they were never part of the recorded
history. This drops the settlement predicate and the attribution machinery
that existed only to keep steps from outliving an excluded parent: with
mid-transition parents now included, no step is orphaned. Snapshot tests
move to the new contract — mid-transition push and mid-transition pop both
round-trip, and only an unresumed-paused event is excluded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

네 가지 구현 리뷰를 모두 반영했습니다. 지시 하나당 커밋 하나로, 각 커밋에서 편집→typecheck/build/test 검증을 거쳤고 c0f3d320 위에 쌓았습니다.

F1 — rebaseNavigationEvents: static까지 함께 rebase (7f2c7b29)

[...staticEvents, ...navigationEvents]를 하나로 concat해 now − transitionDuration에서 1씩 거꾸로 재기저하도록 바꿨습니다. latestStaticEventDate reduce·window·분수 spacing·퇴화 폴백을 전부 걷어냈습니다. static은 navigation-inert(aggregate가 eventDate를 정렬 키로만 쓰고, transitionDuration은 payload 필드이며, captureSnapshot이 static을 영속화하지 않음)이라 "nav보다 앞"이라는 상대 순서만 지키면 되고, 배열 선두에 두면 단일 후진 walk가 그것을 구조적으로 보장합니다.

지적하신 td=0 상시 폴백도 함께 해소됩니다 — static을 nav와 같이 재기저하면 static-before-nav가 td=0/350 모두에서 구조적으로 성립합니다. rebase.spec.ts의 window 전제 테스트는 두 테스트로 재작성했습니다: (1) static·복원 이벤트를 함께 재기저해 이벤트 수와 무관하게 정적 이벤트가 앞서고 전부 정착하는지, (2) td=0에서도 정적 이벤트가 복원 이벤트보다 앞서는지(회귀 고정).

F2 — captureSnapshot: 기록된 navigation 캡처 (c382340d60681079)

F2 지적을 먼저 그 방향(정착된 navigation만 캡처)으로 반영했다가(c382340d, 이후 의존성 폐쇄 c03e3ee3), 후속 논의에서 capture 모델 자체를 '미정착 포함·unresumed-paused만 제외'로 선회했습니다. 최종 형상은 60681079입니다. (기존 커밋은 무수정으로 보존하고, 최종 정책을 구현하는 forward 커밋으로 대체했습니다 — 순수 revert는 미정착·paused 모두 담던 이전 baseline으로 되돌아가 새 정책과 어긋나기 때문입니다.)

선회 근거: transition은 시각적 효과일 뿐이고, navigation effect 훅(onPushed/onPopped)은 이미 active(미정착) 상태에서 발화합니다. 그래서 미정착 이벤트를 캡처해 load에서 상태로 재구성해도 effect가 누락되지 않고 "상태만 done" 불일치도 생기지 않습니다 — 이는 F2 원 코멘트의 "done 전이 side-effect 누락" 우려가 성립하지 않음을 뜻합니다. 무엇이 히스토리의 일부인지는 "정착했는가"가 아니라 "aggregate에 들어갔는가"로 봅니다.

최종 정책: 로그가 기록한 모든 navigation 이벤트를 캡처하되, resume되지 못한 pause 큐잉 이벤트만 제외합니다 — 그것들은 aggregate에서 quarantine되어(적용되지 않음) 기록된 히스토리의 일부가 아니기 때문입니다. 이로써 정착 술어와, "미커밋 부모 제외 시 step이 고아가 되는" 문제를 막으려 두었던 귀속(attribution) 장치가 모두 불필요해집니다 — 미정착 부모가 포함되므로 고아가 생기지 않습니다. 살아남는 것은 pause 제외 하나입니다.

테스트는 새 계약으로 옮겼습니다: 미정착 push·미정착 pop 모두 왕복(캡처→load 정상 재생, pop 반영)하고, unresumed-paused 이벤트만 제외됨을 고정합니다. F2 방향으로 넣었던 "미정착 제외" 계열 테스트와 고아 방지 테스트는 새 모델에서 무의미해져 정리했습니다.

F3 — 등록 검사 통일: validateEvents로 이전 (7929873c)

validateEvents에 Replaced 등록 검사를 추가하고(Pushed·Replaced 둘 다 이름으로 materialize하는 비대칭을 소스에서 교정), loadSnapshot의 전용 등록 검사 블록을 삭제했습니다. Pushed 가지는 이미 aggregate → validateEvents(throw) → incompatible-events wrap으로 중복이었고, Replaced만 넣으면 그 가지도 중복이 되어 블록 전체를 지울 수 있었습니다. rebase는 eventDate만 바꾸고 validateEvents는 순서 독립이라 pre-rebase→post-rebase 이전에도 결과·에러 kind(incompatible-events)가 동일해 관측 동작은 불변입니다. Replaced 등록 검사는 계약 변경이 아니라 누락 보정으로 보고(config-first에선 replace가 등록된 이름만 타깃), validateEvents.spec.ts에 단위 테스트를 추가했습니다.

이 검사가 이제 aggregate 전 경로(live 포함)에 적용된다는 점(config-first에선 dormant)은 changelog 정확성을 위해 changeset에 한 줄 남겼습니다(4c11f321).

F4 — SnapshotLoadErrorObject.setPrototypeOf 삭제 (1ff0b48b)

라인 36-38(코드+주석)을 삭제했습니다. 빌드 타깃이 es2015(esbuild)·ESNext(tsc)라 네이티브 class/extends Error가 프로토타입 체인을 유지하므로, ES5 down-level workaround인 setPrototypeOf 없이도 instanceof가 동작합니다. 삭제 후 instanceof SnapshotLoadError에 의존하는 테스트(load 복구 분기 검증)가 그대로 통과하는 것으로 확인했습니다.


실측(7커밋 정착 상태): yarn typecheck / yarn build / yarn test 모두 green — core 158 tests, plugin-history-sync 52 tests 포함 전 패키지 통과. lint 경고 수 변동 없음.

ENvironmentSet and others added 2 commits July 10, 2026 17:28
…atics (review: as-is capture)

captureSnapshot previously held one semantic opinion: events queued
behind a pause that never resumed were excluded as "not recorded". Drop
that opinion — core exports the stack it is asked about, as-is. The only
remaining filter is vocabulary: static events (Initialized,
ActivityRegistered) are config-grade information that may legitimately
change across reloads, so the current config re-derives them at load
time instead of trusting the snapshot.

Everything else the stack recorded now rides along, Paused/Resumed
included. A snapshot captured during a pause restores a paused stack —
the queued navigation stays quarantined, the restored visible state
matches what was visible at capture, and resuming in the new session
applies the pending navigation. Whether to capture at such a moment is
the caller's timing choice; deciding it is no longer core's business.

The snapshot event union widens from the six navigation events to
SnapshotEvent (navigation + Paused/Resumed): StackSnapshot.events, the
overrideInitialEvents chain, the onInitialActivityIgnored handler param,
and the load-side structure check all speak the widened vocabulary.
Capture no longer re-aggregates the log — it is a pure log transform
(sort by eventDate, dedupe by id, drop statics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g only statics (review: as-is load)

loadSnapshot previously re-dated the whole replay sequence into the
settled past so every restored activity folded to enter-done. Drop that
normalization: the snapshot's recorded eventDates are the replay truth.
A stack captured mid-transition restores mid-transition, a paused stack
restores paused, and capture∘load is an identity on the snapshot events
(byte-for-byte, eventDate included). Replay order follows the dates, not
the array (core-captured snapshots normalize the array to date order, so
the two only disagree in hand-crafted snapshots).

Only the static events are re-dated, pinned just before the earliest
replayed event: Initialized seeds transitionDuration for every later
reducer step, and a snapshot whose tail is an unresumed Paused would
quarantine statics sorted after it — while their natural "now" dates
would sort after a past-dated snapshot. Snapshot events are never
touched.

Guarantees core no longer provides — a fully-settled restore, and
protection from a capture clock that ran ahead (future dates restore
unsettled until the local clock catches up, and later navigation sorts
after them only once it does) — belong to plugins now: re-date the
sequence in overrideInitialEvents. Tests pin both the as-is behavior and
that escape hatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

스냅샷 capture/load 모델을 논의 결론에 따라 as-is 모델로 바꿨습니다. 핵심은 core가 "무엇이 보존할 가치가 있는 상태인가"에 대한 의미론적 opinion을 갖지 않게 하는 것입니다 — capture는 요청받은 시점의 기록을 그대로 내보내고, load는 그 기록을 그대로 재생합니다. 커밋 2건: bd58f04d(capture) · 1c58ead2(load).

capture — static만 제외하고 전부 as-is (bd58f04d)

captureSnapshot은 이제 이벤트 로그에서 static 이벤트(Initialized·ActivityRegistered)만 제외하고 나머지 런타임 이벤트 전부를 — Paused/Resumed 마커 포함 — 기록된 그대로 내보냅니다. 직전까지 있던 "resume되지 못한 pause 큐잉 이벤트 제외" opinion을 제거해, capture는 aggregate 재실행 없는 순수 로그 변환(정렬·dedupe·static 제외)이 됐습니다.

  • static이 예외인 이유: config·소스 코드 급의 정적 정보라 reload 후 달라질 수 있는 게 맞고, 그 변화를 다루는 것이 load 메커니즘의 관심사이기 때문입니다. load 시점의 config가 재파생합니다.
  • pause 중 캡처하면 Paused 마커와 큐잉 이벤트가 함께 담겨 paused 스택이 paused 스택으로 왕복합니다 — 복원 시 가시 상태가 캡처 시점과 일치하고, 큐잉된 항해는 새 세션에서 resume하면 적용됩니다. paused 상태를 찍을지 말지는 이제 core가 아니라 캡처 호출자(플러그인)의 타이밍 선택입니다.
  • 스냅샷 어휘가 넓어져 SnapshotEvent(탐색 6종 + Paused/Resumed) 유니온이 생겼고, StackSnapshot.events·overrideInitialEvents 체인·load 구조 검사가 모두 이 어휘를 씁니다.

load — 스냅샷 이벤트는 기록된 date 그대로, static만 backdate (1c58ead2)

loadSnapshot은 스냅샷 이벤트를 재기저하지 않습니다. 기록된 eventDate가 곧 재생의 진실이라, 전환 진행 중에 캡처한 스택은 전환 진행 중으로, paused 스택은 paused로 복원됩니다. 이벤트가 byte 단위로 보존되므로 capture∘load는 스냅샷 이벤트에 대한 항등이 됩니다(테스트로 고정).

  • 재기저 대상은 static 이벤트뿐입니다 — 가장 이른 스냅샷 이벤트 직전 시점으로 backdate합니다. static은 재생보다 먼저 적용되어야 하기 때문입니다: Initialized가 이후 모든 reducer 단계의 transitionDuration을 결정하고, 스냅샷 꼬리가 unresumed Paused인 경우 그 뒤로 정렬된 static은 통째로 quarantine되어 복원 스택의 전환·등록 정보가 사라집니다. 반면 static의 자연 date는 "지금"이라 과거 date 스냅샷보다 뒤로 정렬되므로, 시계가 아니라 재생열 기준으로 고정했습니다.
  • "모든 복원 activity가 정착 상태"라는 보장은 core 기본에서 빠졌습니다. 캡처 세션의 시계가 앞서 있던 스냅샷(미래 date)도 그대로 재생되어 로컬 시계가 따라잡을 때까지 미정착으로 남습니다. 이런 정규화가 필요한 쪽은 플러그인이 overrideInitialEvents에서 재생열을 직접 재기저하면 됩니다 — 그 escape hatch도 테스트로 데모해 두었습니다.

왜 바꿨나

transition은 시각적 효과일 뿐이고, navigation 이벤트는 stack에 기록되는 순간 반영된 것으로 봅니다. 그렇다면 스냅샷에 찍힌 이벤트를 그대로 load하는 것이 가장 faithful한 복원입니다 — 어떤 상태에서 찍을지(pause 중 포함), 찍힌 것을 어떻게 편집해 쓸지는 core 바깥(공급자·플러그인)의 관심사로 분리하고, core는 왕복의 형식과 재생 기계만 소유합니다. 요구사항과의 관계로는, "스냅샷이 보존한 상태의 충실한 재구성"(R11)을 이보다 충실할 수 없는 형태로 충족하고, pausedEvents·globalTransitionState를 복원 비필수로 둔 R12와도 정합합니다 — 비필수는 폐기를 강제하는 것이 아니라 보존을 허용합니다.


실측(2커밋 정착 상태): yarn typecheck / yarn build / yarn test 모두 green — core 160 tests, plugin-history-sync 52 tests 포함 전 패키지 통과. lint 경고 수 변동 없음.

@ENvironmentSet

Copy link
Copy Markdown
Collaborator Author

checkpoint: 1c58ead

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant