Field-level workflow parity audit β web β Flutter β
Date: 2026-07-14. Phase 3 of the input-parity program (Phase 1 shipped labels, Phase 2 shipped option-sets β both merged). This phase compares behavior, not vocabulary: field presence, conditional visibility, required/defaults, computed/derived writes, and validation bounds, per entity form.
Audited by 7 parallel read-only research passes (one per entity). transactions was excluded: it's a MoJ-scraped, import/display-only table with no user-editable form on either side, so "workflow parity" doesn't apply.
Update (same day): 9 of the 11 priority findings shipped as fixes β web PR aldilaijanre#1078, Flutter PR #358. One finding (#11) turned out to be a false positive on closer verification against the live DB β see its entry below. #9 and several secondary items remain open pending a product decision (not one-line bug fixes).
How to read this β
Each finding is tagged:
- BUG β a real correctness/data-integrity risk, not an intentional platform difference.
- DRIFT β same field, different behavior; may or may not need fixing.
- WEB-ONLY / FLUTTER-ONLY (documented) β the source code itself documents this as deliberate mobile-scope reduction or desktop-only richness. Not a bug.
Priority findings (real bugs, cross-checked against source) β
β FIXED (Flutter #358) β Interactions β Flutter never creates the auto-follow-up reminder. Web's
useCreateInteractioninserts aremindersrow wheneveroutcomeis a needs-follow-up value and afollow_up_dateis set (stampingauto_reminder_created). Flutter'sInteractionsRepository.create()has no equivalent logic at all β an interaction logged from mobile with "needs follow up" silently loses the reminder that logging the same thing from web would have created. Highest-confidence, highest-impact finding across the whole audit.β FIXED (Flutter #358) β Clients β Flutter's duplicate-phone handling can silently redirect into the wrong client. Web's
find_client_phone_duplicatesRPC pre-checks and shows an explicit alert before submit. Flutter has no pre-check; insteadClientsRepository.create()catches ANY Postgres23505(unique violation) and regex-extracts a UUID from the error detail, treating it as "this client already exists, navigate there" β which also matches a genuine phone-collision-with-a-different-person, silently taking the user to an unrelated existing client's record with a success toast. Verified against the liveenforce_client_phone_uniquenesstrigger body: it matches purely by normalized phone within the samesystem, with no notion of "same party" at all.β FIXED (web #1078) β Clients β
entity_typehas no web control at all. The DB column and Flutter's full company/government/bank support exist, but web'sclient_createform_fields never seed anentity_typecontrol β every web-created client is silently pinned to'individual'. Since web is likely the higher-volume creation surface, this probably means most non-individual clients are currently misclassified. Root cause was narrower than it looked:dropdown_optionsalready had the 4 canonical values andClientFormDialogalready had the field fully wired (state/defaults/hydration) β the only gap was the missingform_fieldsconfig row.β FIXED (Flutter #358) β Deals β commission fields can independently disagree. Web computes
commission = amount Γ commission_percentage / 100with no separate commission input, so the three numbers can never conflict. Flutter exposes all three as independent editable fields with no linkage β the same deal can show a different commission depending which app last touched it. Real financial-data-integrity risk.β FIXED (Flutter #358) β Deals β form-driven stage edits skip every side effect. Changing
stagevia web's edit dialog cascades: request status flip, client lifecycle update, WhatsApp notify, auto-reminder. Flutter's dedicated stage-action-bar does a partial subset (closed_at + request status only, no client update / notify / reminder) β and critically, editing stage via the ordinary form Save path (not the action bar) fires zero side effects on Flutter. A stage edited through the form silently drops every downstream effect web guarantees. Note: the fix closes the gap for the subset the action bar already handled (closed_at + linked-request status); full parity on client-lifecycle update / WhatsApp notify / auto-reminder-on-stage-change is a larger follow-up, not replicated here.β FIXED (web #1078) β Valuations β web's staff workflow screen blank-renders 6 of 11 legitimate
workflow_stagevalues.ValuationWorkflow.tsxcastsworkflow_stagedirectly to a UI-stage key with no synonym folding; Flutter'svaluationStoredStageToUiKeymap correctly normalizesdocument_review/inspection_scheduled/inspection/valuation/report_drafted/deliveryonto the 7-key stepper. A row with any of those 6 values (this is most of the canonical vocabulary β see Phase 2 audit) renders with no stage selected on web's staff screen. This is the mirror image of the "workflow_stage UI collapses to 7 keys" pattern already known to be symmetric between the two apps β it turns out only Flutter actually implements the fold correctly.β FIXED (web #1078) β Valuations β "approved before delivered" gate is inconsistently strict on web itself. The stepper-navigation gate accepts
approval_status β {approved, review}as sufficient to reach thedeliveredstage, while the actual "Deliver" button separately enforces strict=== 'approved'. Flutter's single gate is strict everywhere. Not a cross-app drift so much as an internal web inconsistency worth closing to strict-everywhere.β FIXED (Flutter #358) β Properties β numeric fields have zero client-side validation on Flutter.
al_misaha(size),al_sier/al_sawm(price/bid),al_adwar(floor count),irtidad_distance(setback) all lack anyvalidator:in the Flutter form. Web enforcespositive()+ explicit upper bounds on size/price via zod, with a code comment specifically noting the size requirement was a prior data-quality regression fix ("allowing null lets garbage listings into the catalog") β Flutter has silently reintroduced exactly that regression. None of these columns have a DB CHECK, so bad values genuinely persist.OPEN β needs a product decision. Properties β duplicate-listing detection uses entirely different logic per app. Web triggers on blur of the PACI number field, matching by PACI number, offering "proceed" or "deactivate existing." Flutter only checks at submit time, matching by governorate+area+block+plot (parcel) and separately by address+file-number β neither the trigger timing nor the match key nor the remediation options overlap. Two independently-designed duplicate guards protecting the same table. Not fixed: unifying these is a design decision (which match key/trigger point/remediation UX becomes canonical), not a one-line bug fix.
β FIXED (web #1078) β Requests β web's own staff dialog has a dead
al_gharad(purpose) control. The field is fully typed, hydrated, and resubmitted byRequestFormData, but no component inRequestBasicInfo/RequestPropertyPreferences/RequestBudgetTimelineactually renders a control for it β new requests filed via the staff CRM dialog always submitpurpose: null. This is a web bug, independent of Flutter (which fully implements the field). Only the separate public intake form still exposes it.β FALSE POSITIVE β no fix needed.
Requests β client auto-create is asymmetric.The original claim: web's request form auto-creates a newclientsrow when a typed name/phone doesn't resolve to an existing client, while Flutter only resolves against existing ones. This didn't survive verification: Flutter'sresolveClientIdFromNameAndPhonecalls thefind_or_create_unified_personandfind_or_create_client_for_partyRPCs β confirmed against the live function bodies that the latter doesINSERT INTO clients(step 3 of its logic) when no existing/linked/phone-matched row is found. Flutter creates a client the same as web; the two just use different mechanisms (web: a directclientsinsert with nounified_personslinkage; Flutter: routes through the party registry with phone-based dedup). That mechanism difference might itself be worth a look some day (web's path doesn't touchunified_personsat all), but it is not the "Flutter drops new contacts" bug originally reported.
Secondary findings (drift, lower severity or already-mitigated elsewhere) β
| Entity | Finding | Verdict |
|---|---|---|
| Clients | Web's primary-phone validation is a loose /^[0-9+\-\s]{0,20}$/; Flutter enforces strict Kuwait-mobile E.164 normalization. Same clients.phone column ends up in two different formats depending on which app wrote it. | DRIFT |
| Clients | Web requires phone on every edit (not just create) due to a shared form-fields config bug, contradicting its own migration's stated intent; Flutter correctly scopes the requirement to create-only. | DRIFT (web bug) |
| Clients | civil_id and editable assigned_agent exist only on web (Flutter has no field / read-only display). | FLUTTER-MISSING (documented mobile scope) |
| Requests | Budget/size fields: web enforces positive() + upper caps; Flutter's nonNegativeNumber allows 0 and has no upper bound. | DRIFT |
| Requests | Property-type β area gating (web prunes invalid area/category combos) has no Flutter equivalent β mobile can save geographically-invalid combinations web would block. | DRIFT |
| Requests | Area Groups picker and the "Buyer Journey" guided wizard are web-only. | WEB-ONLY (documented mobile-scope reduction) |
| Deals | Stage-transition completeness gate (required fields per stage before advancing) exists on web, not on Flutter β mobile permits advancing with incomplete data web would block. | DRIFT |
| Deals | commission_percentage defaults to '1' on web, null on Flutter for an untouched new deal. | DRIFT |
| Deals | Confirmed FIXED: Flutter's stage dropdown now offers all 7 canonical stages including open (a historical bug, verified resolved). Confirmed SAFE: a web-created off_market deal loads on Flutter without crashing (synthetic fallback menu item), even though Flutter's own create form correctly excludes it as a choice. | β (verification only) |
| Valuations | Per-stage required-field validation, comparable-sales attachment, applicant-type conditional acknowledgement fields, and auto-invoice-on-delivery are all web-only, consistent with the mobile screen's documented scope. | WEB-ONLY (documented) |
| Finance | Web only soft-caps payment amount via an HTML max attribute (not enforced in JS); Flutter strictly blocks amounts exceeding balance due. | DRIFT (Flutter stricter, web allows overpayment) |
| Finance | For mixed per-line tax rates, web's saved invoice/quotation total is computed from a single blended header rate (ignoring per-item tax_rate); Flutter's per-line computation is internally consistent and more correct. | DRIFT |
| Finance | Quotation β invoice conversion exists only on web; no Flutter equivalent found. | WEB-ONLY (not confirmed intentional β worth asking) |
| Finance | Flutter's invoice-status filter chips omit partially_paid as a filterable option, though such invoices still display correctly in the unfiltered list (not hidden). | DRIFT (minor, cosmetic) |
| Finance | Possible shared DB-level issue (not an app-parity bug): the original payment-status trigger migration writes Arabic literals ('Ω
Ψ―ΩΩΨΉΨ©'/'Ω
Ψ―ΩΩΨΉΨ© Ψ¬Ψ²Ψ¦ΩΨ§Ω') while a later CHECK constraint requires English values. Not confirmed whether the trigger itself was ever updated β flagged for separate DB-level verification, out of scope for this audit. | UNVERIFIED β needs its own check |
| Properties | Owner/party assignment: web's schema marks it optional; Flutter's _submit() imperatively blocks create without one β Flutter is stricter than web for the same DB write path. | DRIFT (inconsistent strictness) |
| Properties | Tags editor and photo upload are both deliberately deferred off Flutter's create/edit wizard (explicit code comments), reachable via separate mobile screens instead. | FLUTTER-MISSING (documented mobile-scope reduction) |
Verified matches (no action needed) β
- Properties: building composition / floor-unit breakdown (shares the v3 contract, PR #351) β full parity.
- Requests: price_groups multi-select, al_hala status (no conditional sections on either side).
- Clients: lifecycle_stage has no transition guard on either side; option taxonomies identical (per Phase 2).
- Deals: checklist/doc auto-tracking (
checklist_data.auto) β parity at the detail-screen level. - Interactions: entity-type/entity-id polymorphic linkage, channel/direction/outcome visibility, summary requiredness.
- Finance: payment-method conditional fields (neither side has any),
salary_deductioncorrectly excluded from manual payment dialogs on both sides, invoice-status auto-transition on payment (both apps correctly delegate to the same DB trigger, no app-level logic to drift).
Next step β
9 of 11 priority findings fixed (web #1078, Flutter #358); #11 was a false positive (corrected above); #9 needs a product decision before it can be built. The secondary-findings table above also has several open items worth a decision: requests' budget/size bounds and property-typeβarea gating, deals' stage-completeness gate, finance's quotationβinvoice conversion (web-only β worth confirming intentional) and overpayment validation, and the possible shared DB-level Arabic/English trigger mismatch on invoices.status (flagged as unverified, needs its own check independent of this audit).
Phase 4 (output/PDF parity) is the next phase of the program.
