# Issue 838: Admin Formula Fields In Read Paths

Issue: https://github.com/pingcap-inc/NexusCRM/issues/838
Status: implementation-ready
Date: 2026-05-15

## Purpose

Issue #838 requires Formula fields created through metadata to return computed
values in both SOQL and record detail reads. The fix must stay generic: no
object branches, no tenant field names, no CRM-specific status policy, and no
separate formula read path for one endpoint.

The platform already has the correct runtime shape:

- active formula metadata is compiled into `v_<object>` TiDB views by
  `FormulaViewService`
- `QueryRepository` routes SOQL reads through `FormulaViewService.ResolveTableName`
- `RecordRepository.FindOne` and `FindMany` route record reads through the same
  resolver

The remaining bug is at metadata creation time. A Formula field created through
the plain metadata field API can persist without `FormulaMode=active`. That
turns a read-time formula into an ordinary physical column path, so SOQL and
record reads see `NULL` or a stale stored snapshot instead of the computed
projection.

## Contract

- Admin-created fields with `type=formula` and a non-empty `formula` default to
  `formulaMode=active` when the caller does not explicitly supply a mode.
- Active Formula fields are metadata-only fields. They do not create physical
  columns on the object table.
- Active Formula fields rebuild the object's formula view before the create API
  returns success.
- SOQL, record detail, batch record reads, UI API record reads, and lookup
  hydration continue to use the single generic read resolver rather than
  endpoint-specific formula evaluation.
- Frozen formula fields remain physical snapshot fields and are not changed by
  this issue.
- Formula validation remains the view compiler's responsibility for active
  formulas. The Setup create payload bug remains out of scope, matching the
  issue's non-goal.

## Implementation Shape

Backend metadata creation:

- Add a small metadata helper that normalizes admin-authored Formula field
  metadata before create-time validation and DDL planning.
- Use the helper from both `CreateField` and `BatchCreateFields`.
- Ensure both Tooling CustomField authoring shims preserve
  `Metadata.calculatedFormula`, `Metadata.formulaMode`, and
  `Metadata.returnType`, because the frontend formula creator uses the routed
  `/tooling/sobjects/CustomField` endpoint.
- Treat an omitted or blank `FormulaMode` as `active` only when
  `Type == formula` and `Formula` is non-empty.
- Preserve explicit `frozen` mode so legacy snapshot/backfill behavior remains
  available.
- Reuse one `isActiveFormulaField` predicate for physical-column skipping,
  batch DDL decisions, layout skipping, and post-create view rebuild checks.

Batch metadata path:

- Keep `SchemaRepository.BatchAddColumns` safe for all callers by making it
  metadata-only for active Formula fields.
- Register active Formula field metadata in `_System_Field`, but do not add an
  `ALTER TABLE ADD COLUMN` clause for those fields.
- Pass the batch table name into the shared metadata-write gate so physical
  shape checks still run against the correct object for non-formula fields and
  can intentionally no-op for metadata-only formulas.
- Keep non-formula fields, frozen formulas, polymorphic discriminator columns,
  unique indexes, and foreign keys on the existing batch DDL path.

Read paths:

- No new endpoint-specific formula computation is added.
- Existing repository routing through `FormulaViewService.ResolveTableName`
  stays the source of truth for computed values.
- Missing views continue to fail closed by routing to `v_<object>` and letting
  the query fail, rather than falling back to stale physical data.

## Self-Review Iterations

Iteration 1 traced the issue acceptance through SOQL. `QueryService.Query`
builds field visibility and delegates to `QueryRepository.Find`; the repository
already resolves its `FROM` table through the formula view resolver and uses the
same read table for data, count, filter, sort, MRU, and RLS clauses.

Iteration 2 traced record detail and UI reads. `PersistenceService.Retrieve`,
`RetrieveMany`, `RetrieveWithAccess`, and lookup hydration ultimately call
`RecordRepository.FindOne` or `FindMany`, and those methods already use the same
view resolver. This means endpoint-specific post-hydration formula evaluation
would duplicate the existing source of truth.

Iteration 3 traced field creation. Tooling `CustomField` creation already
defaults formula fields to active mode, but the plain metadata API and batch
metadata API do not. The single-create service can skip physical columns for
active formulas, while the batch repository path currently needs its own guard.

Iteration 4 checked boundary risk. The design uses only generic formula
metadata: field type, formula text, return type, formula mode, and the existing
view materializer. It adds no SObject-name, profile, record-type, approval, or
tenant-specific branch.

Iteration 5 checked compatibility. Explicit frozen formulas still create/read
physical snapshot fields. Existing read resolver tests already cover fail-closed
view routing. New tests only pin the metadata normalization and batch
metadata-only DDL behavior needed for admin-created active formulas.

Iteration 6 checked the batch metadata gate. `BatchSaveFieldMetadata` preloads
physical column specs by table name, so `BatchAddColumns` must pass the object
table name into `FieldWithContext`. With that table name set, non-formula
fields keep the existing physical-shape protection and active formulas remain
metadata-only.

Iteration 7 checked the routed Tooling CustomField endpoint. The older
`MetadataHandler.CreateCustomField` already mapped `calculatedFormula`, but the
active route in `ToolingMetadataHandler` dropped formula-specific fields from
its request struct and normalized `Metadata.type=Text` as a physical string.
The design therefore includes formula payload preservation in the Tooling
normalizer before `MetadataService.CreateField` sees the request.

Iteration 8 checked restart/readiness invariants after the Tooling route starts
creating metadata-only active formulas. `assertFieldColumnMapping` and
`SchemaRepository.ValidateSchema` must only compare metadata fields that are
contractually physical. Active computed fields, identified by
`FormulaMode=active` plus either formula text or projection config, are virtual
and are served through formula views, so they must be excluded from physical
column drift checks.

Iteration 9 checked the record-detail/UI API path. Layout membership is
metadata/UI state, not physical schema state. Active formulas must skip only
physical DDL; they still need the normal `addFieldToLayout` path so record
detail pages request and render the computed view column immediately after a
supported admin field-create action. Single-create and batch-create must stay
consistent here.

Final confidence check: once admin-created Formula fields are normalized to
active mode, active computed fields are consistently metadata-only, and strict
schema verification honors that virtual-field contract, every read surface
named in the issue reaches the already-wired formula view path. I am 100%
confident in this narrow design because there is no second source of formula
values left to keep in sync, no strict verifier still requires a backing
column for active computed metadata, and the UI layout metadata still includes
new admin-created formula fields.

## Verification

Focused tests:

- `cd backend && go test ./internal/bootstrap ./internal/features/metadata ./internal/infrastructure/persistence ./internal/features/analytics`

Baseline checks:

- `cd backend && go test ./...`
- `cd shared && go test ./...`
- `cd frontend && npm run build`
- `git diff --check`

`npm run build` rewrites `frontend/public/version.json`; that generated change
is not part of this issue and should be reverted before commit.
