# Issue 567: Polymorphic Discriminator Immutability

## Summary

Issue #567 is the generic substrate fix for polymorphic lookup discriminator
columns. A polymorphic lookup's `<field>_type` column is system-managed state:
callers may supply the lookup FK, but they must never author the discriminator
directly. The runtime resolves the referenced record and writes the
discriminator from that resolution.

Issue relationship:

- #567 is not blocked by #585 or #587. It fixes the generic platform primitive.
- #585 already shipped the `GroupMember.UserOrGroupId` FK drop in #568. This
  issue finishes the remaining discriminator column by letting it emerge from
  polymorphic field metadata.
- #587 depends on #586. Public CRUD must not accept Group-target
  `GroupMember.UserOrGroupId` rows until transitive group-membership reads are
  correct in RLS; after #586, issue #587 can open that authoring contract.

For the #567 transition, `GroupMember.UserOrGroupId` declared the full stored
target set, `["User", "Group"]`, because repair and discriminator backfill had
to resolve warm-upgraded nested-group rows before the closure rebuild ran. It
temporarily used the generic `writableReferenceTo=["User"]` authoring subset so
user-context writes could only target `User`, while trusted repair/import/
platform contexts could write any declared stored target. Issue #587 removes
that transitional subset after #586's closure read path is available.

## Metadata Contract

Every metadata path that produces table definitions must auto-append one
physical discriminator column for each polymorphic reference:

- name: `<field>_type`
- SQL type: `VARCHAR(255)`
- logical type: `string`
- nullable: `true`

The same generated column must also have `_System_Field` metadata. It is
registered as a normal persisted field for storage/describe consistency, but
its field capabilities are `Createable=false` and `Updateable=false`. This
applies to system tables, source-backed app packs, tenant overlays, and
admin-created polymorphic fields.

Generated discriminator ownership is not persisted as an opaque flag. On schema
load, the platform re-derives ownership from the current object schema: a field
is generated only when it exactly matches `<parent>_type` for a companion
reference field whose metadata is polymorphic or has multiple `referenceTo`
targets. Ordinary fields that happen to end in `_type`, such as
`notification_type`, keep their authored capabilities.

Some non-discriminator fields are also server-owned write surfaces. These use a
generic `_System_Field.WriteProtectionPolicy` metadata fact rather than records
runtime object-name branches. The initial policy, `trusted_only`, makes
`fieldcapabilities.ComputeIntrinsic` describe the field as
`Createable=false/Updateable=false` and makes records runtime reject user
context writes while allowing ServiceContext-and-above platform writers.

Polymorphic reference metadata must round-trip through DDL helpers with
`IsPolymorphic=true`. `referenceTo` is the authoritative list of targets that may
already be stored and must be resolvable by repair. If a rollout needs to expose
only a subset to public authoring, it uses `writableReferenceTo`; it must not
hide real stored targets from `referenceTo` to get a write gate.

## Write-Path Contract

All caller-facing write entrypoints scrub discriminator keys before validation,
FLS checks, formula evaluation, flow-visible state, and SQL writes:

- `Insert`
- `Update`
- `BulkInsert`
- `PreviewBeforeUpdate`
- `CommitPreparedUpdate` extra updates
- `CommitPreparedUpdate` internal updates
- the final `completeUpdateAfterBeforeSave` tail

Before-save flows may repoint the parent FK, but they may not set the
discriminator. After before-save update/preview execution, record state restores
the old discriminator value before recalculating effective updates. The final
post-flow validation tail then reconciles discriminator state from the owning
polymorphic FK:

- absent FK in the effective diff means the old discriminator remains unchanged
- FK set to `NULL` or `""` clears the discriminator to `NULL`
- FK set to a non-empty value is resolved against `referenceTo`, and the
  resolved object API name is written to the discriminator

`validatePolymorphicLookups` remains the single non-empty FK re-derivation
point. Clearing is not a target derivation; it is the null companion state of
the owning FK. No runtime path writes a caller-supplied discriminator value.

User-context writes to fields with `WriteProtectionPolicy=trusted_only` are
rejected from schema metadata at the records entrypoint. This covers the
approval-process work item fields that previously required a hardcoded
`ProcessInstanceWorkitem` branch: `ProcessInstanceId`, `ActorId`, and
`OriginalActorId`. Internal approval writers already use trusted process-table
contexts and continue to author those fields.

Transitional polymorphic fields with `writableReferenceTo` still resolve the
actual target from `referenceTo`, then reject user-context writes whose resolved
target is outside the writable subset. Trusted service/system contexts bypass
that authoring subset for repair, import, and platform maintenance. Marking a
field polymorphic to generate its discriminator must not weaken public write
authorization.

## Repair Contract

Warm-upgraded databases may have polymorphic FK columns without discriminator
values, with stale discriminator values from older code paths, or with stale
discriminators left behind after a nullable FK was cleared. Repair runs an
idempotent backfill over managed table definitions:

- reads every row with either a non-empty polymorphic FK or a non-empty
  discriminator
- clears `<field>_type` when the owning FK is `NULL` or `""`
- resolves non-empty FKs against their declared `referenceTo` targets
- updates `<field>_type` when missing or wrong for a non-empty FK
- fails closed with table, field, row, FK, and target diagnostics when no target
  row can be resolved

The repair runs after schema reconciliation and after the legacy
`GroupMember.UserOrGroupId` FK drop, so the discriminator columns exist before
backfill starts. System repair covers substrate managed tables; pack and tenant
apply paths run the same backfill after their own table definitions have been
reconciled.
