# ADR-083: Form Validation Strategy — Client-Side Feedback, Server-Side Enforcement

## Status

Accepted, 2026-06-27.

## Status History

```yaml
status_history:
  - date: 2026-06-27
    status: Proposed
    changed_by: hkl
    reason: Formalising form validation split between client (UX feedback) and server (business logic enforcement) across all PWAs
    changed_via: adr-kit (360lm)
  - date: 2026-06-27
    status: Accepted
    changed_by: hkl
    reason: Standard established; all new and existing PWAs follow this validation contract
    changed_via: adr-kit (360lm)
```

## Context

360lm PWAs handle complex forms spanning multiple domains:
- **Custodian:** Transfer forms (NEFT/UPI/cash with conditional fields), payee selection, amount entry.
- **Finance family:** Expense claims, salary disbursement, bill uploads, reconciliation.
- **Sales:** Invoice creation, rate card comparison, client billing.
- **HR:** Salary input, employee disbursement, advance requests.
- **Activity:** Client portal event details and feedback.

Per ADR-014 (PostgREST as API layer) and ADR-075 (RPC error format), all business logic is enforced in PostgreSQL RPCs and database constraints:
- NOT NULL, CHECK, UNIQUE, and FK constraints at the DB layer.
- Business rules (amount limits, status transitions, balance checks) in trigger functions and RPCs.
- RPC failures raise `RAISE EXCEPTION` → HTTP 400 with user-readable messages (ADR-075).

**Current state (as of 2026-06-27):**
- Client-side validation is inconsistent: some PWAs check for required fields, others skip validation and rely entirely on server errors.
- Field error highlighting is ad-hoc (no standard pattern for focus, border color, error message placement).
- Real-time validation is not standardised (some PWAs validate on every keystroke, others validate only on submit).
- Required field markers are missing or inconsistent (some use `*`, others use no indicator).
- No documented pattern for distinguishing client-validation vs. server-enforcement responsibilities.

**Problem:** Without a unified strategy, new forms either duplicate business logic (risky, causes inconsistency), or omit client-side checks and force users to wait for server round-trips to discover basic errors like empty required fields. This leads to poor UX and higher API load.

## Decision

**Form validation follows a two-layer model:**

1. **Server is the source of truth** — All business rules, constraints, and authorization checks are enforced in PostgreSQL. Client validation is UX only; server errors always win.

2. **Client-side validation** (performed on submit, before fetch):
   - **Required field presence:** Check that mandatory fields (marked with `*` in label) are non-empty.
   - **Basic format checks:** Non-zero amounts, valid date format, non-empty strings, email structure if applicable.
   - **Purpose:** Fast feedback, reduce unnecessary API calls, improve perceived performance.
   - **No business logic duplication:** Do NOT replicate DB CHECK constraints, balance checks, or status transitions in JS.

3. **Real-time validation** (during editing):
   - **Only for immediate-feedback fields:** Amount formatting (per ADR-071), character count limits on text areas, date picker constraints.
   - **No real-time cross-field validation:** Too complex to maintain (e.g., "amount must not exceed available balance") — done at submit time.
   - **No third-party library:** Use native HTML5 validation attributes (`maxlength`, `min`, `max`) and simple inline JS; no external validator library.

4. **Field highlighting on error:**
   - On form submit, if client-side validation fails, add `style="border-color: red; border-width: 2px"` to the first invalid field.
   - Call `field.focus()` to scroll the field into view and focus the cursor.
   - Remove the red border on the next `input` event (user starts correcting the field).
   - No error message popup — the red border + focus is sufficient. Tooltip optional but not required.

5. **Required field markers:**
   - Append ` *` (space + asterisk) to the `<label>` text for all mandatory fields.
   - Do NOT use HTML5 `required` attribute (triggers browser-native popups that conflict with custom UI).
   - Example: `<label>Transfer Amount *</label>`

6. **Server error display** (from ADR-075):
   - RPC errors (HTTP 400): `showToast(e.message)` where `e.message` is the user-readable exception from PostgreSQL.
   - Constraint violations (HTTP 409): Show generic message: `"Operation failed — please check your input."`
   - Internal errors (HTTP 500): Show generic message: `"An error occurred. Please try again."`
   - No field-level error highlighting for server errors (error is already shown in toast; user sees context from RPC message).

7. **No client-side schema duplication:**
   - If DB has a CHECK constraint `amount > 0`, the client checks `amount > 0 before submit` for UX only.
   - Client does NOT replicate complex CHECK logic (e.g., `(status = 'pending' AND amount <= 10000) OR (status = 'approved' AND amount <= 1000000)`).
   - Server always applies the full constraint; client only checks "not obviously empty/malformed."

**Validation Responsibility Matrix:**

| Check | Client (Submit) | Server (RPC) | Where Server Wins |
|-------|-----------------|-------------|-------------------|
| Required field non-empty | ✓ | ✓ (NOT NULL) | 404, "Field required" |
| Amount > 0 | ✓ | ✓ (CHECK) | 400, "Amount must be positive" |
| Valid email format | ✓ (optional) | ✓ (DB or RPC) | 400, "Invalid email" |
| Amount ≤ available balance | ✗ | ✓ (RPC logic) | 400, "Insufficient balance" |
| Date in valid range (e.g., not future) | ✗ | ✓ (CHECK or RPC) | 400, "Date must be in past" |
| Status transition allowed | ✗ | ✓ (RPC check) | 400, "Cannot transition from X to Y" |
| Unique email / phone | ✗ | ✓ (UNIQUE constraint) | 409, "Email already registered" |
| FK valid (payee exists) | ✗ | ✓ (FK constraint) | 409, "Payee not found" |

**Decision Maker:** hkl

## Implementation Notes

### Field Highlighting Pattern

```javascript
// On form submit
function submitForm() {
  const form = document.getElementById('my-form');
  
  // Client-side validation
  const requiredFields = form.querySelectorAll('input[required], textarea[required]');
  let firstInvalid = null;
  
  for (const field of requiredFields) {
    if (!field.value || field.value.trim() === '') {
      field.style.borderColor = 'red';
      field.style.borderWidth = '2px';
      if (!firstInvalid) firstInvalid = field;
    } else {
      field.style.borderColor = ''; // Clear if now valid
      field.style.borderWidth = '';
    }
  }
  
  // Amount fields: check > 0
  const amountFields = form.querySelectorAll('input[data-amount]');
  for (const field of amountFields) {
    const amount = _parseAmt(field); // Per ADR-071
    if (amount <= 0) {
      field.style.borderColor = 'red';
      field.style.borderWidth = '2px';
      if (!firstInvalid) firstInvalid = field;
    } else {
      field.style.borderColor = '';
      field.style.borderWidth = '';
    }
  }
  
  if (firstInvalid) {
    firstInvalid.focus();
    return; // Stop; user must fix the field
  }
  
  // Proceed to fetch
  submitToServer(form);
}

// On any input event, clear the red border
document.addEventListener('input', (e) => {
  if (e.target.style.borderColor === 'red') {
    e.target.style.borderColor = '';
    e.target.style.borderWidth = '';
  }
});
```

### Required Field Marker

```html
<!-- Correct -->
<label>Transfer Amount *</label>
<input type="text" id="tf-amount" inputmode="decimal" data-amount>

<!-- Incorrect (do not use) -->
<label>Transfer Amount</label>
<input type="text" id="tf-amount" required>
```

### Server Error Handling (Per ADR-075)

```javascript
async function submitToServer(form) {
  const resp = await fetch('/db/rpc/some_function', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({...})
  });
  
  if (!resp.ok) {
    const error = await resp.json();
    if (resp.status === 400) {
      showToast(error.message); // User-readable from RPC
    } else if (resp.status === 409) {
      showToast('Operation failed — please check your input.');
    } else {
      showToast('An error occurred. Please try again.');
    }
    console.error('RPC failed:', { status: resp.status, body: error });
    return;
  }
  
  const result = await resp.json();
  showToast('Success!');
  // Redirect or refresh as needed
}
```

### Amount Field with Formatting and Validation

Per ADR-071, amount fields use Indian number formatting:

```html
<label>Transfer Amount *</label>
<input type="text" id="tf-amount" inputmode="decimal" 
       data-amount oninput="fmtAmountInput(this)">
```

Client validation:
```javascript
const amountEl = document.getElementById('tf-amount');
const amount = _parseAmt(amountEl);
if (amount <= 0) {
  amountEl.style.borderColor = 'red';
  amountEl.style.borderWidth = '2px';
  amountEl.focus();
  return;
}
```

### Text Area with Character Limit

```html
<label>Notes (max 500 characters)</label>
<textarea id="notes" maxlength="500" 
          oninput="updateCharCount(this)"></textarea>
<span id="char-count">0 / 500</span>
```

```javascript
function updateCharCount(textarea) {
  const count = textarea.value.length;
  document.getElementById('char-count').textContent = count + ' / 500';
}
```

### Files and Locations

- **Validation pattern reference:** All PWA `index.html` files include the submit validation pattern above.
- **Key function names:**
  - Client: `submitForm()`, `_parseAmt()` (from ADR-071), `fmtAmountInput()` (from ADR-071)
  - Server: RPC definitions in `/migrations/` with `RAISE EXCEPTION USING MESSAGE =` (ADR-075)
- **Find all form submissions:** `grep -r "submitForm\|async.*fetch.*rpc" /var/www/360lm --include="*.html" | head -20`
- **Find all amount fields:** `grep -r "data-amount\|inputmode=\"decimal\"" /var/www/360lm --include="*.html" | head -20`

### Gotchas

- **Do NOT validate business logic on client:** Never check "amount ≤ available balance" on client. This logic changes and must live in the RPC only. Client only checks "amount > 0".
- **Do NOT use HTML5 `required` attribute:** It triggers browser-native validation popups that may conflict with custom styling. Use `required` as a data attribute for semantic markup, but do NOT rely on the HTML5 validation popup.
- **Do NOT add third-party validators:** No external library like Joi, Yup, or VeeValidate. The patterns above are simple and do not warrant a dependency (ADR-013 — single-file PWAs).
- **Server errors do not highlight fields:** When the RPC returns HTTP 400, show the error in a toast (ADR-075), not by highlighting a field. The user sees the toast message and understands which field caused the issue from the RPC message (e.g., "Payee not found" clearly indicates a payee selection error).
- **Real-time validation for format only:** Only apply real-time validation for fields that have immediate feedback value: amount formatting (ADR-071), character counts, date picker constraints. Do NOT apply real-time validation to cross-field constraints.

## Alternatives Considered

- **Validate all business logic on client (replicate DB constraints in JS).** Rejected: Leads to inconsistency when rules change; server is the authority. Client validation duplicates code and is a maintenance burden. Use this only for "does the value look sane" checks, not for business rules.

- **Validate everything on server; no client-side checks.** Rejected: Poor UX — users must wait for API round-trip to discover a required field is empty. Increases API load. Client validation is cheap and improves perceived performance.

- **Use a third-party validation library (Joi, Yup, VeeValidate).** Rejected: Violates ADR-013 (single-file PWAs, no dependencies). The validation patterns above are simple enough not to warrant a library. Adding a library introduces build step complexity and external maintenance burden.

- **Use HTML5 `required` attribute and native browser validation.** Rejected: Browser-native popups are not customizable and conflict with custom UI styling (error highlights, toast messages). The pattern above gives full control over error UX.

- **Highlight all invalid fields on every keystroke.** Rejected: Too noisy; distracts users while they are still typing. Only highlight on submit, when the user has finished entering the field.

- **Show field-level error messages below each field (label + error tooltip).** Rejected: Adds HTML clutter and complexity. A single toast message at the top of the page is sufficient; the user understands which field caused the issue from the RPC error message.

## Consequences

### Positive

- **Clear responsibility split:** Client validation is UX only; server is authoritative. Easy to reason about and maintain.
- **Consistent UX across PWAs:** All forms use the same validation pattern — red border on first invalid field + focus + toast on server error.
- **No business logic duplication:** Developers are not tempted to replicate DB constraints in JS; the pattern makes it clear what belongs on the client vs. server.
- **Fast feedback on obvious errors:** Required field checks and amount > 0 are instant; no API round-trip needed.
- **Simple, no external dependencies:** Uses native HTML5 attributes (maxlength, min, max) and vanilla JS. No validator library, no build step.
- **Easier to debug:** Client-side errors are separate from server-side errors. Toast messages are always from the RPC (server truth), making error diagnosis clear.

### Negative / Trade-offs

- **Two validation phases:** Developers must implement both client-side (for UX) and server-side (for truth). This is necessary but adds a small implementation burden.
- **Potential for mismatch:** If client validation and server validation diverge (e.g., client checks amount > 0 but server check is amount ≥ 1000), users may see confusing errors. Mitigation: code review checklist (ADR-068) and clear comments in RPC definitions.
- **No real-time error feedback for complex rules:** Complex validations (balance checks, status transitions) can only be checked on submit, not during editing. User must wait for RPC response. Mitigation: RPC responses are fast (local DB); acceptable latency.
- **Field highlighting only for client validation:** Server errors are shown in toast only, not as field highlights. Mitigation: RPC error messages are explicit (e.g., "Payee not found" clearly indicates the payee field); user understands which field caused the error.

### Risks and Mitigations

| Risk | Mitigation |
|------|-----------|
| Developer replicates complex business logic on client (e.g., balance check), causing false positives | Code review checklist (ADR-068) requires reviewing all client-side validation. Comments in RPC clarify which validation is server-only. |
| Server validation changes but client validation is not updated, causing inconsistency | RPC changes go through VCC (ADR-068); reviewer checks both client and server. Document server-side validation in RPC comments. |
| User gets confused by field error (red border) then server error (toast message) for the same field | This is rare (client validation passes, server constraint fails). RPC message must be clear: "Payee not found" not "FK constraint violation." ADR-075 enforces user-readable messages. |
| New developer forgets to add required field marker (`*`) or client validation check | ADR-068 (VCC checklist) item includes checking for required field markers and client-side validation on new forms. |
| Field highlighting is not removed after user fixes the field (border stays red) | The `oninput` event listener clears the border. Test this during PWA review. |

## Related Decisions

- **ADR-075** (RPC Error Response Format) — defines HTTP status codes and error message format from server; client uses `showToast(error.message)` to display them.
- **ADR-071** (Indian Number Formatting) — amount fields use `fmtAmountInput()` for real-time formatting; client validation checks `_parseAmt(field) > 0`.
- **ADR-013** (Single HTML File, No Framework) — PWAs are self-contained; no external validation library permitted.
- **ADR-014** (PostgREST as API Layer) — all business logic is in PostgreSQL RPCs; client validation is UX only.
- **ADR-068** (VCC Pre-Build Safety Checklist) — code review checklist includes verifying client-side validation and required field markers.

## References

- `memory/feedback_verify_pin_response.md` — Table RPC response format (ADR-006 basis).
- `memory/feedback_pg_trigger_security_definer.md` — Trigger functions raising exceptions (server validation).
- `/var/www/360lm/docs/input_and_image_standards.md`, Section 1 — Amount input formatting with cursor preservation.
- `/var/www/360lm/tests/custodian.spec.js` — Example Playwright test validating form submission and error handling.
- ADR-075 code snippets — RPC error handling pattern in all PWAs.

---

**Changed via:** adr-kit (360lm)  
**Last updated:** 2026-06-27
