v2 — Learnings from v1

Mistakes we made. Don't repeat them.


Stack-level mistakes

Astro was the wrong meta-framework for this app

Mistake: Picked Astro because it was familiar and easy to start with. Why it bit: Astro optimizes for content-heavy mostly-static sites with islands of interactivity. CareerVector is the opposite — interactive multi-state editor with realtime sync. Wrong shape. Pages adapter quirks, islands awkwardness, --commit-dirty=true hacks, lock-in to Pages-specific bindings. v2 lesson: Match meta-framework SHAPE to app SHAPE. SPA-shape app with realtime → SvelteKit (or similar SPA-first meta-framework). Not Astro.

React was a defensible choice for v1, but not optimal for v2

Mistake: Default-React without comparing alternatives. Why it bit: Bundle size (40KB framework), virtual DOM diffing on a busy editor adds up. Fine, but not lean. v2 lesson: Svelte 5 (or Solid) wins on bundle + perf for editor-heavy apps. Pick deliberately.

Cloudflare Pages was wrong tier

Mistake: Pages-with-Workers-functions adapter for what is fundamentally a Worker app. Why it bit: Hidden friction. The deploy needs --commit-dirty=true workarounds, the function adapter has its own quirks. v2 lesson: Workers + Workers Assets directly. No Pages.


Data layer mistakes

Position-derived gid generation is a bug class

Mistake: sectionGroupId(idx) = "g-s-${idx}" ran on every load via normalizeStack. Inserting a new section made it inherit a persisted gid from a now-shifted neighbor → collision. Real corruption seen in production (header glued to projects, header glued to side-ventures, header glued to pb). Why it bit: Code that LOOKS like idempotent migration ("fill in missing data on read — safe right?") was actually generating colliding IDs whenever positions shifted. Defensive code WAS the bug source. v2 lesson: Identity is data, set at creation, never derived. Use nanoid (or UUIDs) at birth, never compute from position.

Auto-fix-on-load is the bug, not the safety net

Mistake: normalizeStack ran on every load and "fixed" missing variantGroups. This masked the state-machine bugs that should have been impossible to introduce. Why it bit: When the lifecycle is broken upstream, auto-fix-on-load papers over symptoms while creating new bugs (collision mode above). v2 lesson: State machine handles correctness at write time. Reads assume canonical input. Migration is a one-off boundary, not running code. NO FALLBACKS.

JSON blobs in D1 work for v0 but lose history/sync semantics

Mistake: Stored cv_profile, cl_profile, settings, layout, order_state as JSON TEXT columns. Read whole, write whole. Single workspace.version counter incremented on every write. Why it bit: No incremental sync. No history. No replay. Reconnecting clients must full-refetch. Concurrent writes resolve by last-write-wins with ad-hoc field merge — which is fragile and bug-prone. v2 lesson: For multi-cooperator editable documents, use a CRDT (Yjs). Y.Doc IS the source of truth. Ops log + snapshot beats JSON blob + version counter for collaboration semantics.

Pure WS relay was insufficient

Mistake: Durable Object as a pure broadcast relay with no persistence. When dormant, state was lost. Reconnecting clients had to refetch from D1. Why it bit: Multi-tab sync was fundamentally broken (BUG-2). Disconnect+reconnect could miss updates. v2 lesson: DO holds the canonical Y.Doc, persists ops + snapshots, brokers Yjs deltas. State survives hibernation. New clients sync via Yjs awareness.


Bundle / perf mistakes

Static imports of heavy SDKs leak into eager bundle

Mistake: src/lib/providers/adapter.ts had import statements for all 14 @ai-sdk/* packages. Anything that touched adapter.ts (transitively) pulled in 800KB. Why it bit: The hooks file useAiHealth.ts only needed a constant from adapter.ts but the static import dependency chain dragged everything. v2 lesson: Constants in their own file. Implementation that imports heavy deps stays separate, lazy-imported by the consumers that need it.

Top-level imports of export libs was a leak

Mistake: src/lib/export.ts had top-level import * as XLSX etc. The dynamic outer wrapper await import('../lib/export') looked correct but the inner static imports leaked deps. Why it bit: ~900KB of xlsx + jspdf + html2canvas in the eager bundle for users who never exported. v2 lesson: Dynamic imports must go ALL THE WAY DOWN. await import() inside the function that actually uses the dep, not at the module level.

Astro static imports of "lazy" components don't actually lazy

Mistake: Had to keep checking that React.lazy wraps were applied correctly. v2 lesson: Convention-driven meta-framework conventions (SvelteKit's +page.svelte, lazy-by-default route segments) reduce this surface area.


Test mistakes

Migration tests outlive the migration

Mistake: v1 → v2 migration code ran every normalize. We had unit tests verifying it. Then the bug was discovered to be in the migration code itself. Why it bit: Tests of "migrates correctly" gave false confidence that the live code was correct. They were testing obsolete behavior. v2 lesson: Migration is one-off. Test with one-off scripts. Don't keep migration tests as ongoing CI gates after the migration is done.

Sonnet's "shipped" verdict misses real bugs

Mistake: Subagent reports said "tests pass, build clean, shipped." Codex review found data-loss-class P1 bugs in the same commits. v2 lesson: Codex review on every meaningful commit. Subagent self-reports are necessary but not sufficient.

Behavior tests are load-bearing artifacts, not regression-guards

Mistake: Treated tests as "extra" instead of as the spec. v2 lesson: When picking between fix and test-the-fix, prefer test-first. The test description IS the product spec. Future agents read tests to understand what should happen.


Workflow mistakes

Sequential agent dispatch wastes time

Mistake: Spawned one agent, waited for completion, spawned next. v2 lesson: Pre-partition work into N disjoint scopes, dispatch N agents simultaneously. Each agent owns a file or directory. Zero merge conflicts. Real parallel speedup.

File conflicts kill parallel agents

Mistake: Two agents tried to edit journeys.spec.ts simultaneously. One swept up the other's staged changes by mistake. v2 lesson: Each agent writes to a NEW file when possible. Different files = guaranteed no conflict.

Larger agent batches > smaller batches

Mistake: Spawning 3 agents to each implement 1 stub when 1 agent could implement 4 stubs. v2 lesson: Coordination overhead scales with agent count. One Sonnet doing 3 stubs in one commit beats 3 Sonnets doing 1 each. Reserve parallelism for genuinely independent units.


UX-level mistakes

Lazy-loaded components without error boundaries deadlock

Mistake: WorkspaceNotes used React.lazy + Suspense. If the lazy import rejected (chunk load error, race), Suspense fallback stayed permanent. "Loading editor..." forever. v2 lesson: Every Suspense boundary needs an error boundary. Empty-state UI should not require waiting for full editor to load.

Aggressive auto-generation startles users

Mistake: CL generation fired immediately when user first selected a job. GENERATING banner appeared before user even reviewed the quarry. v2 lesson: Auto-actions should require either pre-existing state (like a tailored_cl already saved) or explicit user click. Don't fire LLM calls on first encounter.

Placeholder seeds become noise

Mistake: Workspace creation seeded 2 "Untitled" placeholder jobs to demonstrate the UI. Users saw them as phantom data. v2 lesson: Empty state UI > placeholder data. Empty Discovery column should show "No jobs yet — paste a URL above" not 2 fake jobs.

Cryptic abbreviations need tooltips

Mistake: Custom scoring criteria columns shown by default with 3-letter abbreviations (Tim, Gro, Orc, Own, Rar, Cul). No hover, no explanation. v2 lesson: Default to hidden for noisy custom columns. Always provide hover tooltip for any cryptic label.


Security mistakes

Server env keys leaked to browser

Mistake: GET /api/chain?workspace= merged server env API keys into the response. Codex review caught it. Why it bit: Anyone with workspace URL could fetch unmasked GROQ_API_KEY etc. v2 lesson: Mask by default. Dev-mode unmasking is an exception, not the rule. Codex review on every endpoint that returns key material.

This is intentional but worth documenting: with no auth, anyone with workspace URL can read/write. CRDT bindings give them ops apply rights. There is no workspace privacy without auth — and we've decided no auth is right for the use case. Just be aware.


Workflow-level lessons

Codex + Sonnet + Gemini in rotation

Codex is the best reviewer (catches what Sonnet's "shipped" misses). Sonnet is the best generalist coder. Gemini is Haiku-tier — fits mechanical/repetitive work.

Dispatch all three in parallel on disjoint scopes when token budget permits. Each provider has independent rate limits — using all three avoids being throttled on any single one.

.git read-only in codex sandbox

Codex sandbox mode has .git read-only. Codex can't commit even when work is correct. Workaround: I commit on its behalf based on staged working tree.

Document decisions in .agent/runs/ (or docs/ here)

Future agents have no memory of conversations. Memos persist. When making a non-obvious decision, write it down in a markdown file with the why, not just the what.


Chunk G observations (CVL editor port)

Yjs concurrent lazy-create of nested types loses data

Observation: Two clients that both lazy-create a sub-Y.Map under the same key on a fresh Y.Map produce a CRDT merge that picks one and silently drops the other. This is fundamental Yjs semantics — getMap(key) is a local operation; if both sides write at the same key with different sub-types, the integration order picks one. Implication: Any "if the container doesn't exist, create it" pattern is a data-loss vector under realtime concurrency. The broker (or a one-time workspace-init step) MUST seed shared containers before client edits can race. Action: sections.create is now strict — refuses to lazy-create the lang slot. Bootstraps via cv_profile.replace / cl_profile.replace. Codex caught this on commit 9b1b66f (P1). Future: Add a lang.init { profile, lang } op or have the broker seed cv_profile.en / cl_profile.en at workspace creation.

Yjs transactions don't roll back when callback throws

Observation: doc.transact(() => { map.set('a', 1); throw; }) LEAVES a=1 in the doc. Transactions in Yjs batch observer events; they don't have rollback semantics like SQL. Implication: Validation that throws partway through a transaction leaves the doc in an intermediate state. Pre-scan inputs BEFORE any mutation. Action: sections.update now pre-scans patch keys for forbidden fields (id, variantGroup) before any sectionMap.set. Codex P2 on commit 9b1b66f.

variantGroup transfer needs a dedicated atomic op

Observation: v1's ContentEditor handles variantGroup JOIN/LEAVE via inline cascading sections.update calls — bullet/sub/subheading add or remove triggers reassignment of multiple variantGroups + groupState. The straight section-shaped ops can't model this atomically. Action: sections.update patch type now excludes variantGroup. Future op sections.transferVariantGroup { sectionId, newGroupId, groupStatePatch } should land before the variantGroup UI is wired up.

v1 patterns lifted as-is (Chesterton's fence, "looked weird" but kept)

These all looked like they could be cleaned up but were kept verbatim per PRINCIPLES.md #14:

  • ADDABLE_SECTIONS flat const with singleton flag — the singleton: true exemption from slug-disambiguation is load-bearing for header, pb, signature. v1:285-296 → v2 ContentEditor.svelte module script.
  • emptyData blob per addable — feels duplicative with the section schemas, but each consumer (LLM prompt builders, Typst serializer, the ruler) reads slightly different fields. Don't consolidate.
  • g-s-${nanoid(8)} variantGroup prefix — v1 always namespaces; the g-s- prefix is greppable for debugging and doesn't collide with any other id namespace. Kept.
  • Empty-data fixed sections distinguish kind by data flag ({ header: true }, { subject: true } etc.) — looks like a tag enum would be cleaner, but the data record is what _underscoreFields injection (_headerTypst etc.) keys off of. Don't refactor.
  • Doc-type model is 4 panes (cv | cl | info | format) — info + format don't render to typst, but they're still in the same tab strip because the header overflow indicator + format conventions impact what cv/cl render. Tabs ≠ rendered docs.

Codex hit usage limit during review

Observation: codex review --commit <SHA> returned a usage-limit error after the second review attempt this session. Could not complete review on 90f43a3 (CVL scaffold). Action: Manually walked through the commit to flag obvious issues; remaining review deferred to ChunkG-2.


Future refactor tags (not built now)

Pool surfacing — Mode B (history pane for inactive pool members)

What it is: An alternative editor layout where inactive pool members are hidden behind a "show history" button rather than always displayed inline. Today's v1-verbatim Mode A (always visible, dimmed at opacity-50/60) is shipped for v2.

Why deferred: Mode A is battle-tested. Mode B introduces a visibility toggle that needs careful UX design (discoverability of hidden variants) and has no proven advantage over Mode A for typical CV editing sessions.

When to revisit: If user research shows that editors with large pools (10+ variants per node) find the always-visible layout noisy. Design should start from "how does the user know there are hidden variants?" before touching any code.



v2.1 Backlog — Deferred findings from the audit cycle

Sourced from: CHESTERTONS-FENCE-AUDIT, CHAOS-AUDIT, V1-V2-PARITY-AUDIT, CVL-UNIFICATION-AUDIT, A2-REVIEW, A-REVIEW-BUG-F, V1-V2-RUN-REPORT, MCP-COVERAGE. Compiled 2026-04-26. Entries below are deferred — not tracked in current tasks, not already-fixed P0/P1 items, not active design decisions.


1. Code patterns to revisit

1.1 uniqueValues derived store has no debounce — v1's 300ms guard was load-bearing

Dashboard.svelte computes uniqueValues as a synchronous $derived.by, re-running on every keystroke touching any job's organization, location, industry, or source. The v1 comment explicitly named the debounce as protection for DataCell memoization: "prevents re-rendering the entire table (and breaking DataCell memoization) on every single keystroke." Also, title was included in v1's map but was dropped in v2. Source: CHESTERTONS-FENCE-AUDIT P0-3.

1.2 createLogger vs raw console.error — v1 used namespaced opt-in loggers

v2 components call raw console.error in 10+ places where v1 used createLogger('namespace') with per-namespace toggle via localStorage.debug. Two callers in v2 still use createLogger; the rest don't. Either roll createLogger out consistently or document the relaxation — the mixed state leaks raw error objects to the console in production. Source: CHESTERTONS-FENCE-AUDIT P1-3.

1.3 tailor_cv / tailor_cl write-path inconsistency — one bypasses the broker

tailor_cv in mcp/src/tools/tailor_cv.ts wrote directly to D1 while tailor_cl used jobs.update — two different write paths for structurally identical operations. The ops-layer unification collapsed these into a single tailor tool (commit e771e7c). Worth auditing during v2.1 to ensure no remaining direct-D1 writes exist outside the backup path. Source: CVL-UNIFICATION-AUDIT P0.

1.4 /cv/generate and /cl/generate are copy-paste duplicates

The two route handlers differ only in column name, body field name (profile vs letter), and log label — all boilerplate. Collapsing to POST /tailored/generate?docType=cv|cl with a single handler is trivial and removes a maintenance surface. Source: CVL-UNIFICATION-AUDIT P1.

1.5 profileToJobKey logic duplicated in both tailored.snapshot and tailored.promote switch arms

apply.ts derives jobKey from branch_path in two identical inline patterns. Extract a one-line profileToJobKey(profileKey) helper. No behavior change; prevents the third profile-type divergence. Source: CVL-UNIFICATION-AUDIT P1.

1.6 MCP tool names lag behind the op rename

Three tools registered as add_section, update_section, move_section now wrap node.create, node.update, node.move respectively. The name/op mismatch misleads every agent that reads the tool list. Also mcp/test/tools.test.ts still asserts old op kinds (sections.create, sections.update) and cannot pass. Source: MCP-COVERAGE §1a.

1.7 ADDABLE_SECTIONS flat const with singleton flag — lifted verbatim, ugly but load-bearing

The singleton: true exemption prevents slug-disambiguation for header, pb, signature. It's the correct behavior; the structure looks like it could be a richer type but changing it would break consumers that key on singleton. Chesterton-deferred — document, don't simplify. Source: LEARNINGS §Chunk G.

1.8 emptyData blob per addable — duplicative but multi-consumer

emptyData overlaps with section schemas, but LLM prompt builders, the Typst serializer, and the ruler each read slightly different fields. Do not consolidate without reading all three consumers first. Chesterton-deferred. Source: LEARNINGS §Chunk G.

1.9 getRealtimeBinding return type is narrower than runtime contract

api.ts declares { fetch(req: Request): Promise<Response> } | null but the fix in commit 45254ce calls the two-arg fetch(url, init) overload, which only works because the binding is any-cast internally. Widen to match the Cloudflare Fetcher interface so future callers don't need to puzzle it out. Source: A-REVIEW-BUG-F §Minor concern.

1.10 CvGenerator.svelte seeds CL tree with root id 'cv-root'

The CL quarry tree gets a root node whose id literal is 'cv-root'. Structurally harmless under CVL unification (ids are opaque), but confusing in raw Y.Doc inspection. Rename to 'doc-root' when touching that file — no logic change needed. Source: CVL-UNIFICATION-AUDIT P2.


2. Architectural debt to consider

2.1 Broker fan-out is fire-and-forget — stale-tab risk has no mitigation

POST /api/workspaces/:id/jobs inserts into D1 then fans out to the broker via waitUntil. If broker dispatch fails silently (binding absent, network error, 5xx), other open tabs never receive the new job until page reload or reconnect. No retry, no background reconcile. The fix (A-REVIEW-BUG-F) surfaces the modes but doesn't add reconciliation. Document the trade-off in REALTIME-DESIGN.md; revisit if broker-fault rate is observable. Source: A-REVIEW-BUG-F §Y.Doc consistency.

2.2 REALTIME binding absent in prod has no log line

When getRealtimeBinding(platform) returns null, the broker fan-out silently skips with no warning. In production this means realtime is wholly disconnected with zero observability. A one-line console.warn in the non-test path is the fix. Source: A-REVIEW-BUG-F §Top-3 concerns.

2.3 Yjs awareness vs broker split — stale peer count on flaky reconnect

On rapid disconnect/reconnect (laptop sleep), each reconnect can produce a new Yjs clientID if WorkspaceConnection.destroy() is not called. The awareness state accumulates stale entries visible until DO eviction. Multi-tab presence indicator can show N+1 peers. Fine at low churn; could confuse users. Source: CHAOS-AUDIT §1.5.

2.4 maxCount on selection.update is advisory, not enforced

apply.ts stores maxCount on the node but never checks active.length <= maxCount before applying setActive. The field is a no-op constraint today. If the tree-of-choices loop needs a hard cap, this needs enforcement. Flagged as OQ-4. Source: A2-REVIEW P3/§3.4.

2.5 tailored.snapshot / tailored.promote only wrote cv_profile — CL path was missing

apply.ts:208-228 hardcoded doc.getMap('cv_profile'). The A2 review flagged this as P1-4; subsequent CVL unification and tailor tool landed the fix via branch_path. Verify in v2.1 that no direct getMap('cv_profile') remain in the apply path where getMap('cl_profile') should also be reachable. Source: A2-REVIEW P1-4.

2.6 BYOK key rotation mid-pipeline uses stale cache

createChainConfigLoader() caches Promise<ChainConfig> indefinitely; only invalidate() resets it. If a user rotates a key in the Models tab while a tailorJob is mid-flight, the next stage reuses the deleted key, gets 401, and isProviderLevelFailure may not catch it — the cascade retries the dead key on every slot. There is no path that forces invalidation when a CLOUD_KEYS PATCH lands. Source: CHAOS-AUDIT §2.2.

2.7 Per-call timeout absent from cascade

callWithChain has no AbortController per provider attempt. A hung provider blocks all subsequent fallbacks forever; the user's spinner is permanent. Even with 6 providers, one hung connection blocks all. Source: CHAOS-AUDIT §2.4.

2.8 Critic rejection on last slot returns degraded result silently

ai.ts:296-303 — if the critic rejects on the last cascade slot, the result is returned anyway with no wasCriticDegraded flag. The Quality LED can show green on a critic-rejected tailoring pass. Source: CHAOS-AUDIT §3.6.

2.9 D1 backup failure is silent — no metric, no alert

backupToD1 catches all errors with console.warn and no retry, no alarm escalation. A misconfigured D1 binding causes silent backup failure forever; RPO is unbounded. Source: CHAOS-AUDIT §6.1.

2.10 No Y.Doc / D1 drift detector

y_snapshot_clock is written on backup but nothing compares it against the live DO clock. A "last backup N hours ago" indicator is trivially derivable from this column. Source: CHAOS-AUDIT §6.6.

2.11 Convention resolution breaks when format moves into a per-language tree

v1 resolves conventions from format.lang via resolveConventions(lang). If v2 eventually moves format into a per-language sub-tree (per tree-of-choices §3.1), the resolution chain gains a new level. The resolve-stack.ts lift is correct today but needs a review when that tree change lands. Source: V1-V2-PARITY-AUDIT §5.8.

2.12 Read-only mode honored at layout level only

view/[wsId]/+layout.svelte sets cv:readOnly context, but getContext('cv:readOnly') returns zero hits across deep modal components. Deep links into /view/.../cv could expose write affordances in ContentEditor and modals that don't read the context. Source: V1-V2-PARITY-AUDIT §5.9.

2.13 Profile narrative has no Y.Doc root or REST path

src/components/profile/ProfileEditor.tsx exists and writes a markdown blob injected into every tailoring LLM call via tailor/context.ts. v2's page is a stub and no profile.narrative Y.Doc root or op exists. Until this lands, agent-driven tailoring runs with a blank narrative and quality degrades. Source: V1-V2-PARITY-AUDIT 4.7, MCP-COVERAGE D9.

2.14 harness dev-mode: shared D1 state between web and realtime workers is undocumented

Two wrangler.toml files declare the same database_id, but miniflare gives each app its own .wrangler/state folder. The realtime worker's zombie gate returns 404 for workspaces created via the web app. The shared-state requirement (--persist-to=../../.wrangler-shared/state) is documented in STAGING.md.


3. v1 bugs ported (intentionally Chesterton-deferred)

3.1 Kanban maxId+1 pattern — positional id race

v1 assigned new kanban phase/status ids as max(existing ids) + 1. Concurrent inserts from two tabs can produce the same id. v2 lifted the pattern verbatim per PRINCIPLES.md #14. Replace with nanoid at creation time when touching the kanban settings op. Source: CHESTERTONS-FENCE-AUDIT (pattern class, related to CHAOS §1.1).

3.2 Salary as numeric with coercion quirks

normalizeSalary in llm.ts:132-138 coerces values ≥ 999 by dividing by 1000 (treating them as monthly values). A salary of "999" is left as-is while "1000" becomes 1 — off-by-one boundary. Different cascade providers extract different raw values for the same posting, causing non-deterministic salary normalization across re-extracts. The v1 logic was lifted verbatim. Source: CHAOS-AUDIT §2.5.

3.3 File ownership check fails on last entry of misc array

GET /api/files/:fileId uses INSTR(files, '"file_id":N,') — the trailing comma means the last entry in a misc[] array is never matched (no trailing comma after the last element). A v2 P1 improvement added the ends-bracket needle for the last entry, but this fix was noted as a v1 backport candidate. Source: CHESTERTONS-FENCE-AUDIT P2-1, CHAOS-AUDIT §4.5.

3.4 v1 cloud_keys GET masks provider entries as flat strings

src/lib/api-handlers/get.ts:140 iterates provider entries as if they were strings; they are objects {keys: string[]}. The mask result destroys the per-key structure. This is a v1 production bug — v2's maskCloudKeys is structure-preserving. Document explicitly so the "BYOK consistent across GET/PUT" promise is explicit in the v2 surface. Source: V1-V2-RUN-REPORT BUG-D.

3.5 Migration script does not deduplicate id='header' collision

scripts/migrate-to-tree.ts maps v1 sections to nodes by section.id, but v1 production data had multiple sections with id='header'. Two siblings with the same id land in children[] without error because the schema does not enforce child-id uniqueness. The second 'header' becomes ghost-addressable via resolveNodePath. Fix: in migrateDocumentStack, track seen: Set<string> and re-mint nanoids on collision, re-keying selection.active accordingly. Source: A2-REVIEW P1-2.


4. Test infrastructure debt

4.1 Bundle-split test not ported

v1's tests/unit/bundle-split.test.ts asserting that no @ai-sdk/* provider package leaked into the eager bundle has no v2 equivalent. SvelteKit chunking differs from Vite/Rollup but the same leak class is possible. A v2 equivalent should check that lib/domain doesn't import provider SDKs at module scope. Source: V1-V2-PARITY-AUDIT §6.1.

4.2 sync-realtime.test.ts not ported — multi-client convergence unasserted

v1's sync-realtime.test.ts asserted ordering invariants and offline-queue ordering across tabs. Yjs largely subsumes merge correctness, but the broker snapshot/compaction path and end-to-end multi-client convergence at the op level are unasserted in v2. Source: V1-V2-PARITY-AUDIT §6.1.

4.3 Missing test: cloud-keys write-block over WebSocket

No test asserts that a cloud_keys.update op arriving over the client WebSocket is actually rejected — only the schema validation path is tested. Need a service-binding integration test that confirms the reject frame is sent and the client Y.Doc is not mutated. Source: V1-V2-PARITY-AUDIT §6.3.

4.4 Missing test: schema-reject UI propagation

connection.ts logs a warning on MSG_SCHEMA_REJECT and calls onSchemaReject?.(msg) — but no callsite in Dashboard / CvGenerator / KanbanBoard passes that callback. A test should assert: broker rejects an op → client Y.Doc reverts to pre-op state → user sees some signal. Source: V1-V2-PARITY-AUDIT §3.8.

4.5 Missing tests: tailored.snapshot invariants

A2-REVIEW P3 lists three missing cases: (a) mutating the quarry after snapshot must not affect tailored_cv (fork independence, Decision 4); (b) calling snapshot twice must overwrite (re-tailor semantics); (c) maxCount enforcement — selection.update setActive longer than maxCount should be rejected, but the field is currently advisory. Source: A2-REVIEW P3.

4.6 Missing test: node.create concurrent duplicate-id guard

CHAOS-AUDIT §1.1 identifies that if two tabs call node.create with a caller-supplied id (e.g., deterministic slug 'header'), both inserts land after Yjs merge because the uniqueness check runs on each tab's local replica. A Yjs-level multi-client test that exercises this race would pin the invariant. Source: CHAOS-AUDIT §1.1.

4.7 Missing test: POST /api/workspaces/:id/jobs broker-failure resilience

The BUG-F fix wraps broker dispatch in a try/catch so the handler returns 201 even when the broker errors. No unit test covers "binding absent → 201", "fetch throws → 201", "5xx from broker → 201". A ~30 LoC vitest would prevent this class of regression across all REST handlers. Source: A-REVIEW-BUG-F §Recommended test.

4.8 e2e spec agent-api-parity.spec.ts anchored to v1 path-API

The lifted spec asserts REST paths from v1's apiPut(workspaceId, 'settings/info/name', value) pattern — paths that return 410 in v2. The spec needs a re-anchoring pass for the architectural shift before it can serve as a green CI gate. Source: V1-V2-PARITY-AUDIT §6.2.

4.9 Recursive selection invariant not tested at depth > 1

NodeSchema.refine() fires per child (via z.lazy) so nested violations are caught — but no test actually constructs a depth-2 violation and asserts rejection. Add a permanent test in nodes.test.ts so a future schema rewrite can't silently regress this. Source: A2-REVIEW P1-1 (demoted to P3).

4.10 Yjs transaction non-rollback not documented in test

apply.ts's pre-scan of forbidden fields (id, variantGroup) is what makes the "atomicity" claim in nodes.test.ts:278 true — not Yjs transactional rollback (which doesn't exist). A code comment should say this explicitly. If someone reorders the check after the mutation, the test will still pass but the invariant will be broken. Source: A2-REVIEW P3 §5.


5. Performance items to revisit at scale

5.1 Y.Doc size budget — tailored CVs are never pruned

apply.ts writes the full tailored tree onto each job node; no op exists to clear it. At 1k jobs × ~20KB tailored CV = 20MB Y.Doc. Re-tailoring overwrites in place, but a job kept around forever accumulates tailored snapshots. gc:true is set on the broker Y.Doc but only GCs deleted items. Add a tailored.clear op or prune on re-tailor. Source: CHAOS-AUDIT §9.1.

5.2 Cell origin orphan accumulation

Every cell update writes to JobData.origins; no prune fires when a column is deleted. Orphan origin keys for removed custom columns accumulate in every job forever. Source: CHAOS-AUDIT §9.4.

5.3 Catchup cap is 2MB; 20MB doc sends full-state per reconnect

CATCHUP_CAP_BYTES = 2_000_000. At 20MB doc size, the diff will exceed the cap and the broker falls back to full state on every reconnect — single WS frame, no streaming. On CF Workers Free tier, the WS message limit may not be 1MB, but this is worth validating at the ~500-job scale. Source: CHAOS-AUDIT §9.3.

5.4 Snapshot compaction writes the full state every 200 ops

SNAPSHOT_OP_THRESHOLD = 200. A 20MB workspace that generates 200 ops/wake triggers a 20MB SQLite write per compaction. DO storage I/O is metered; this becomes a cost concern at scale. Source: CHAOS-AUDIT §9.2.

5.5 R2 prewarm: no CDN prewarm strategy for Typst WASM

The Typst WASM (~25MB) is lazy-loaded from CDN on first use. No prewarm on workspace load. Cold-start compile on first CV preview is slow; returning users experience the delay again after cache eviction. A <link rel="preload"> or service-worker cache strategy would help. Source: V1-V2-PARITY-AUDIT §2 (Typst preview row).


6. Documentation gaps

6.1 DEV-SETUP.md missing — shared D1 state between web and realtime workers is undocumented

Without --persist-to pointing both wrangler dev instances to the same state directory, the realtime worker's zombie gate rejects every workspace created via the web app. This is the single biggest dev-mode footgun; the setup lives in STAGING.md (shared .wrangler-shared/state/).

6.2 Harness gotchas not captured in a contributor guide

Run-report §Harness setup lists seven non-obvious setup requirements (fish shell PATH, port conflict behavior, test.skip inside describe invalid, v1 DELETE body vs query, etc.). These live only in the run report. A short e2e-compare/README.md would prevent re-discovery on each re-run. Source: V1-V2-RUN-REPORT §Harness setup gotchas.

6.3 The "410 Gone — moved to MCP" lie on /api/extract-fields and /api/evaluate

Both endpoints return 410 with a comment claiming the functionality "moved to MCP" — but neither MCP tool was built. Any external client or documentation following that claim will find nothing. Either build the tools and remove the lie, or change the message to "not implemented". Source: MCP-COVERAGE E1, E3; V1-V2-PARITY-AUDIT §3.10.

6.4 REALTIME-DESIGN.md missing the fire-and-forget broker trade-off

The stale-tab risk from fire-and-forget broker dispatch (D1 insert succeeds, broker fails silently, stale tabs never hydrate the new job) is a deliberate trade-off. See REALTIME-DESIGN.md for the broker contract and the "no periodic reconcile" decision rationale.

6.5 tailored.promote default mode 'variant' is undocumented as a product decision

apply.ts:233 defaults mode to 'variant' matching OPEN-QUESTIONS.md OQ-1's lean. The actual decision was never locked — the TODO in TREE-OF-CHOICES still reads "TBD". Either lock OQ-1 (recommend requiring explicit mode) or document the lean-default as intentional. Source: A2-REVIEW P2-2.

6.6 slug-index.ts slug-reorder discipline must not be simplified in ChunkG-2

v1's makeId + SlugIndex keeps Typst section anchors stable across renames. The stub in ContentEditor.svelte:30 marks it TODO. When ChunkG-2 lands, the author must read V1-V2-PARITY-AUDIT §5.5 before implementing — a fresh-id-on-rename approach would break _recipientName placeholders and tailoring prompt context. Document this in the ChunkG-2 task brief. Source: V1-V2-PARITY-AUDIT §5.5.

6.7 Notes editor status needs a decision record

v1 had a 1057-LoC ProseMirror collaborative notes editor; v2 dropped it. The /api/notes endpoint returns 410 but V1-V2-PARITY-AUDIT §4.6 flags the intent as "unclear — intentional drop or port miss?" Until a decision is recorded, any agent porting v2 features may accidentally re-implement or delete a planned feature. Source: V1-V2-PARITY-AUDIT 4.6, MCP-COVERAGE G1.

Source: wiki/content/investigations/LEARNINGS.md