Improve codebase architecture · deepening opportunities

ClientsFlow Studio — architecture review

Where the Reviewer subsystem (Comment → triage → AI edit → Version) has gone shallow — interface nearly as complex as implementation — and state has lost locality, scattering across the web/ layer. Each candidate deepens a module: more behaviour behind one interface, one place to test. Aligned to new-plan.md (mutable working version + comment state machine + pure-core extraction).

2026-06-22· scope: app/web/, app/core/, app/static/· vocabulary: module · interface · depth · seam · leverage · locality
module seam leakage deep module Strong Worth exploring Speculative

1 · A Comment state machine behind one seam

Strong in-process · pure

app/models.py (Comment.status — 8 states, a code comment) · revision_svc.py (_on_triage_done · _on_staging_apply_done · _on_apply_done · discard_staging) · revision_api.py (reject_comment · apply_batch) · comments_api.py (submit)

Before — status set in 7 places

field

Comment.status

no enum · no guard

_on_triage_done
→ needs_designer
_on_apply_done
→ applied
_on_staging_apply
→ applied
discard_staging
→ open
submit
draft → open
reject_comment
→ rejected

Each writer owns a slice of the truth. The bugs (rejected shows AI text, accidental “Kész”) live between them.

After — one transition seam

submit
reviewer
apply worker

seam

deep module

core/comment_state.py

transition(c, event) → Comment

not_done ⇄ done ⇄ rejected · locked (derived)

One pure function owns every legal move. Callers pass an event; the DAG is enforced once.

Problem. 7 write sites mutate Comment.status with no enum and no transition guard — the state machine has no locality.

Solution. A pure transition() module enforces the legal DAG; all sites call through it.

  • locality: transitions in one module
  • leverage: one interface, 7 call sites
  • interface is the test surface — no DB
  • deletion test: scatter reappears → earns keep
  • kills “rejected shows AI text” class
  • maps to new-plan §2

2 · Collapse version minting into one version_lifecycle seam

Strong in-process · pure plans

revision_svc.py (get_or_create_staging · finalize_staging · _on_apply_done · _next_n ×3 · discard_staging) · revision_api.py (accept_preview · publish) · dashboard.py (publish_all) · intake_svc._upsert_page · importer.import_page

Before — 8 sites touch the version lifecycle

flowchart TD
  A[get_or_create_staging]:::w --> V[(Version rows
draft·staging·preview·published)]:::leak B[_on_apply_done]:::w --> V C[finalize_staging]:::w --> V D[intake._upsert_page]:::w --> V E[importer.import_page]:::w --> V F[accept_preview]:::w --> V G[publish]:::w --> V H[dashboard.publish_all]:::w --> V N{{_next_n race counter}}:::leak -.dup ×3.-> A N -.-> B N -.-> C classDef w fill:#fff,stroke:#94a3b8; classDef leak fill:#fef2f2,stroke:#dc2626,stroke-width:2px;

After — pure plans, one writer executes

flowchart TD
  subgraph LC["core/version_lifecycle.py  (deep)"]
    direction TB
    P1[plan_checkpoint]:::d
    P2[apply_section_edit]:::d
    P3[restore_previous]:::d
    NX[[_next_n internal]]:::int
  end
  callers[web writer
max_containers=1]:::w --> LC LC --> V[(working · published
2 states)]:::ok classDef d fill:#0f172a,color:#e2e8f0,stroke:#334155; classDef int fill:#1e293b,color:#94a3b8,stroke:#334155; classDef w fill:#fff,stroke:#94a3b8; classDef ok fill:#ecfdf5,stroke:#10b981;

Problem. A new Version is minted/promoted at ~8 sites with no central guard, and the _next_n race counter is duplicated 3× with different exclude_id logic.

Solution. A deep module returns pure plans (checkpoint / section-edit / restore); the single web writer executes them. _next_n becomes internal.

  • leverage: 8 sites → one interface
  • locality: one off-by-one, one place
  • plans are pure → testable without DB
  • 5 statuses shrink to working/published
  • unblocks restore-previous (new-plan §5)
  • maps to new-plan §1
Contradicts CLAUDE.md hard rule #4 (“Versions are immutable”). The deepening makes the working version mutable while keeping published versions byte-immutable (sha1 manifest per edit). Worth reopening — new-plan.md §1 already calls for editing rule #4. Record the decision as an ADR (docs/adr/) so future reviews don’t re-litigate it.

3 · Drafts: one source of truth behind draft_reconcile

Strong in-process · pure

static/shell.js (storeKey embeds versionId · mergeServerDrafts · draftStats) · comments_api.py (autosave · submit vs _submit_page_drafts · sweep) · viewer.py (my_draft_total · pages_with_drafts)

Before — a draft lives in 3 places, reconciled by a race

localStorage cfs:{token}:{slug}:{versionId}

versionId null/changes → orphaned key

20s poll race

server autosave

submit ≠ _submit_page_drafts (2 paths)

mergeServerDrafts

client vs server count diverge

Duplication on A→B→A; deletions not authoritative.

After — server is the source; localStorage is a cache

shell.js

offline cache only · reconcile by serverId

deep module

core/draft_reconcile.py

upgrade_drafts() · draft_counts()

idempotent upsert by client_uid + temp_id

Tested against the exact “A→B→A” and “delete then navigate” sequences.

Problem. Drafts live in localStorage + server autosave + mergeServerDrafts, reconciled by a race; the versionId in the key collides when null — no locality, duplication on navigation.

Solution. Server is the single source; a pure reconcile module owns dedupe + counts; localStorage becomes a thin cache keyed by page + client_uid.

  • kills navigation duplication
  • one count source — no divergence
  • submit/submit_all share one path
  • authoritative deletes
  • pure → testable as sequences
  • maps to new-plan §4, §7

4 · Split the revision_svc god-module

Worth exploring in-process

app/web/revision_svc.py — 625 lines, 6 concerns · _build_section_ops duplicated (lines 207–233 ≈ 327–345) · append_editlog/read_editlog reconstruct AI history from Job rows · revision_api._page_comments_triage does the same scan

Before — six concerns share one DB session

staging lifecycle
AI job orchestration
comment-status transitions
carry + reanchor
edit-audit log (Job scan)
section-ops (duplicated)

After — thin service over deep cores

absorbed into cores

comment_state · version_lifecycle · draft_reconcile · edit_audit

section_plan() — the de-duped _build_section_ops

stays thin

revision_svc = orchestration + DB writes only

Problem. 625 lines mix staging, AI orchestration, comment transitions, audit, and section ops; _build_section_ops is copy-pasted; the Job table is an implicit event store scanned two ad-hoc ways.

Solution. Extract candidates 1–3 first; the remainder is thin orchestration. Dedupe section ops into one section_plan(); give the audit log its own module (reuse the R2 editlog).

  • section-op bug fixed once
  • audit stops coupling to Job format
  • mostly falls out of 1–3
  • enables EditAudit (new-plan §6)
  • locality across the Reviewer
  • deletion test: it’s a real seam

5 · Make the anchor contract explicit

Speculative cross-language seam

static/overlay/overlay.js (buildAnchor) ⇄ core/reanchor.py · core/rewrite.py (window.__CFS__ shape) · revision_svc._carry_and_reanchor

Before — implicit JS ⇄ Python contract

flowchart LR
  O[overlay.js
buildAnchor]:::w -. "anchor JSON
(implicit keys)" .-> R[reanchor.py]:::leak O -. "window.__CFS__
(implicit shape)" .-> W[rewrite.py]:::leak classDef w fill:#fff,stroke:#94a3b8; classDef leak fill:#fef2f2,stroke:#dc2626,stroke-width:2px;

A key rename on either side breaks silently — no test crosses the seam.

After — one documented schema at the seam

flowchart LR
  O[overlay.js]:::w --> S{{anchor schema
shared constant}}:::ok S --> R[reanchor.py]:::w S --> W[rewrite.py]:::w classDef w fill:#fff,stroke:#94a3b8; classDef ok fill:#0f172a,color:#e2e8f0,stroke:#334155;

reanchor.py (already deep + pure) gets a thin seam tested directly.

Problem. The anchor object and window.__CFS__ shape are implicit contracts spanning JS and Python; nothing tests across the seam.

Solution. One documented anchor schema at the seam; expose reanchor through a thin interface tested directly. Lower priority — surfaced for completeness.

Top recommendation

Start with #1 comment_state.py, then #2 version_lifecycle.py.

These two are the shared “risky core” of new-plan.md (M1 → M2) — build them identically whether the team picks the full rebuild or the targeted re-architecture, and the A-vs-B decision becomes visible at the model boundary. #1 is the smallest, highest-leverage, zero-invariant-conflict place to begin: a pure state machine, the interface is the test surface, and it dissolves the “rejected shows AI text / accidental Kész” class of bugs before any model migration. #2 follows immediately but needs the hard-rule-#4 decision recorded as an ADR first.

Sequencing: #1 → #2 → #3 (draft reconcile) → #4 falls out of the first three → #5 only if the cross-language seam bites.