# Issue 837 Formula Field Metadata Preservation

Issue: https://github.com/pingcap-inc/NexusCRM/issues/837
Status: design locked before implementation
Date: 2026-05-15

## Purpose

Issue #837 covers a Setup custom-field create bug: a user can configure a
Formula field, but the normal Tooling API create path persists it as ordinary
text because the backend drops formula-specific metadata before it reaches the
metadata service.

The fix is contract preservation. Creating a formula field must keep the
metadata fact as metadata:

- the field's logical type is `formula`
- the expression is stored on `_System_Field.Formula`
- the return type is stored on `_System_Field.ReturnType`
- the formula compute mode is stored on `_System_Field.FormulaMode`
- describe reports the field as calculated and exposes `calculatedFormula`

Formula evaluation for record reads and SOQL is intentionally out of scope for
this issue.

## Current Failure

The frontend create adapter posts to:

`/services/data/v63.0/tooling/sobjects/CustomField`

For formula fields, the payload includes formula-specific members in
`Metadata`, including `calculatedFormula`, `formulaMode`, and `returnType`.

The route is now owned by `dialect-sf-crm` through
`ToolingCustomFieldCreateNeutralHandler`, which calls the dialect-local
`normalizeToolingCustomFieldRequest`. The original bug was in the earlier
backend REST shim: that normalizer's request structs did not bind the formula
members. It saw `Metadata.type = Text` for a text-returning formula, mapped
that to internal `string`, and built `models.FieldMetadata` without `Formula`,
`ReturnType`, or `FormulaMode`. The metadata service then did exactly what it
was asked to do: create a plain string field.

There is a second UI symptom after the backend bug is fixed. Salesforce-style
describe reports formula fields with the return type in `fields[].type` and
`calculated = true`. The Object Manager edit modal currently initializes its
local form type from `editingField.type`, so a formula returning text reopens as
Text unless the modal also checks `calculated` or `calculatedFormula`.

## Contract

- `dialect-sf-crm` is the public create path for Setup custom fields. Its
  Tooling custom-field handler must preserve all formula metadata it receives
  and pass a single `models.FieldMetadata` fact to `MetadataAuthoring.CreateField`.
- The metadata PATCH path must preserve the same formula fact shape on edit.
  `calculatedFormula` is accepted as a Salesforce-describe alias, but update
  persistence writes through `FieldMetadata.Formula`.
- Active formula edits must rebuild the formula view synchronously before the
  update is considered successful. If the rebuild fails, the previous field
  metadata is restored and the old view is repaired before returning an error.
- The normalizer may accept both `calculatedFormula` and `formula` as input
  aliases, but the internal model always stores the chosen expression in
  `FieldMetadata.Formula`.
- A non-empty expression means the internal logical type is `formula`, even
  when the Tooling `Metadata.type` carries the formula's display/return type.
- `returnType` is authoritative when present. If a formula payload omits it but
  uses a Salesforce field type in `Metadata.type`, the normalizer may infer the
  internal return type from that display type. The metadata service remains the
  final fail-closed gate for missing return type.
- `formulaMode` is preserved when supplied. If omitted for a formula field, the
  create path defaults to `active`, matching the existing formula-view contract.
- Blank or whitespace-only formula expressions are rejected. They must not be
  coerced through the Tooling adapter as ordinary text fields.
- Non-formula field behavior stays unchanged.
- Describe behavior stays Salesforce-shaped: formula fields report
  `calculated = true`, populate `calculatedFormula`, and expose the return type
  as `fields[].type`.
- Object Manager read surfaces use `calculated` or `calculatedFormula` to treat
  described return-type fields as formulas. Edit mode reopens the formula
  editor, and the field list displays/filter formulas as Formula with the
  describe return type as secondary detail.
- No object-specific, tenant-specific, profile-specific, picklist, or business
  status behavior is introduced.

## Salesforce Boundary

The changed route is under `backend/internal/interfaces/rest/`, so the v63
contract checklist applies. The relevant external surfaces are Salesforce
Tooling API custom-field create/read semantics and REST SObject describe
semantics.

References:

- Tooling API CustomField:
  https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm
- Metadata API CustomField:
  https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/customfield.htm
- REST API SObject Describe:
  https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_describe.htm

NexusCRM's `formulaMode` is a platform extension for active-view versus frozen
snapshot formulas. It is not Salesforce app/business policy; it selects generic
formula runtime substrate and therefore belongs with field metadata.

## Implementation Shape

Backend:

- Extend the Tooling custom-field request metadata structs with:
  `calculatedFormula`, `formula`, `formulaMode`, and `returnType`.
- During normalization, merge top-level aliases into `Metadata` the same way the
  handler already merges label, type, precision, scale, reference, and picklist
  inputs.
- Resolve the formula expression from `calculatedFormula` first, then `formula`.
- If the resolved expression is non-empty:
  - set `field.Type = constants.FieldTypeFormula`
  - set `field.Formula`
  - set `field.FormulaMode` to the supplied value or `active`
  - set `field.ReturnType` from `returnType` or the mapped display type
- Wire `MetadataService` to the formula view rebuilder and make active formula
  updates fail closed: save the merged metadata, invalidate the schema cache,
  rebuild the view immediately, and roll back to the prior field metadata if
  the rebuild fails.
- Keep existing picklist, lookup, length, precision, scale, default, and help
  text handling unchanged.

Frontend:

- Keep `metadataAPI.createField` posting formula metadata in the Tooling
  `Metadata` envelope.
- Map formula edits to the metadata PATCH shape by sending `formula` for the
  stored expression. Preserve `formulaMode` and `returnType` with that update.
- Do not synthesize `formulaMode = active` during edit. New formulas default to
  active, but editing an existing formula with no described mode must omit
  `formulaMode` so the backend preserves the stored mode.
- For `type = "formula"`, carry explicit formula intent even when the expression
  is blank so the backend rejects incomplete formulas instead of mapping them to
  text.
- Add adapter coverage so formula fields cannot regress to a payload that lacks
  `calculatedFormula`, `formulaMode`, or `returnType`.
- In `FieldDefinitionModal`, initialize edit state as `type = "formula"` when a
  described field is calculated, and initialize `returnType` from the described
  field's `type`.
- In `ObjectFieldsTab`, filter and display through the same calculated-field
  helper so text-returning formulas appear under the Formula filter.

## Tests

Backend focused tests:

- The dialect-local `normalizeToolingCustomFieldRequest` preserves a formula payload with
  `calculatedFormula`, `returnType`, and `formulaMode`.
- The same test verifies that the internal type is `formula` even when
  `Metadata.type` is `Text`.
- A fallback test covers `formula` as an expression alias and default
  `formulaMode = active`.
- Existing non-formula mapping remains covered by current behavior; add a small
  assertion if needed to prove ordinary text still maps to string.
- `MetadataService.UpdateField` persists formula expression, return type, and
  formula mode together when rebuild succeeds.
- `MetadataService.UpdateField` returns an error and restores the previous
  formula metadata when the synchronous formula view rebuild fails.

Frontend focused tests:

- `createField` sends a formula create payload with
  `Metadata.calculatedFormula`, `Metadata.formulaMode`, and
  `Metadata.returnType`.
- `createField` includes blank formula intent for incomplete formulas, and the
  backend rejects blank/whitespace formula expressions.
- `updateField` maps `calculatedFormula` to `formula`.
- Formula edit payload construction defaults new fields to `active`, preserves
  an existing mode when present, and omits `formulaMode` when describe lacks it.
- The Object Manager modal's edit-state helper treats a described calculated
  text formula as a formula editor state with `returnType = "string"`.
- Describe field display helpers treat `calculated = true` return-type fields as
  Formula for list rendering and filtering.

Baseline verification:

- `cd backend && go test ./internal/interfaces/rest`
- `cd frontend && npm run test -- schemas FieldDefinitionModal`
- `cd frontend && npm run build`
- `git diff --check`

Full repository gates remain:

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

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

## Self-Review Iterations

Iteration 1 traced the browser path. `FieldDefinitionModal` and
`metadataAPI.createField` already have the intended formula members in the
create payload, so the initial suspicion that the form builder alone was at
fault was incomplete.

Iteration 2 traced the backend route table. The active Tooling route is
`ToolingMetadataHandler.CreateCustomField`, not the older
`MetadataHandler.CreateCustomField` helper. The active normalizer drops the
formula members because they are missing from its structs.

Iteration 3 checked describe reopening. The backend describe path already
reports formula metadata correctly once the stored field is really logical
`formula`, but the Object Manager edit modal needs to interpret Salesforce's
`calculated` flag because describe's `type` is the return type.

Iteration 4 checked the L0/L1 boundary. The design adds no CRM object facts, no
tenant policy, and no object-name branches. It only preserves generic field
metadata supplied through an existing Tooling API shape.

Final confidence check: the create bug has one authoritative backend loss point
and one UI reinterpretation gap after persistence succeeds. Preserving the
formula members in the Tooling normalizer, then using `calculated` to reopen the
formula editor, satisfies every acceptance bullet without adding formula
evaluation or app-specific behavior.

Iteration 5 checked formula edit persistence. PATCH now writes the formula fact,
but the view rebuild still needed to be part of the update contract. The design
therefore makes active formula edits fail closed in `MetadataService.UpdateField`
instead of relying on the best-effort asynchronous metadata event listener.

Iteration 6 checked formula mode preservation. Salesforce describe does not
carry NexusCRM's `formulaMode`, so the edit modal must not fill in `active` on
save for existing formulas. Omitting the field lets `MetadataService.UpdateField`
preserve the stored value, including frozen formulas.
