|
| 1 | +# FR-013 — Field-level `@readOnly` attribute design |
| 2 | + |
| 3 | +**Status:** Design (ready for implementation plan) |
| 4 | +**Applies to:** all five language ports (TypeScript reference + Java / Kotlin / C# / Python). |
| 5 | +**Related ADRs:** [ADR-0001](../../../spec/decisions/ADR-0001-cross-language-type-binding.md) (cross-language native-type binding), [ADR-0002](../../../spec/decisions/ADR-0002-open-closed-typed-nodes.md) (open-closed typed nodes), [ADR-0013](../../../spec/decisions/ADR-0013-logical-field-types-vs-physical-column-attributes.md) (logical/physical layer split). |
| 6 | + |
| 7 | +## Why this doc exists |
| 8 | + |
| 9 | +The metamodel already expresses *entity-level* read-only-ness through `source.rdb @kind` — `view` / `materializedView` / `storedProc` / `tableFunction` make the whole entity read-only. There is no *field-level* read-only mechanism. A writable `@kind: "table"` entity is all-or-nothing today, which is wrong for five common shapes every adopter eventually needs: |
| 10 | + |
| 11 | +1. **Computed / generated columns** — Postgres `GENERATED ALWAYS AS (...) STORED`, SQL Server computed columns, MySQL generated columns. The database computes the value; the application must not write it. |
| 12 | +2. **Audit timestamps populated by triggers** — `created_at` / `updated_at` written by DB triggers, not by application code. |
| 13 | +3. **Server-generated identity columns on update** — secondary fields populated by `DEFAULT` clauses on update that the application must treat as read-after-write. |
| 14 | +4. **View / projection extension** — an `object.entity` with `@kind: "view"` extending a writable `object.entity` should propagate read-only-ness to inherited fields through the projection, even when the projection re-publishes only some of them. |
| 15 | +5. **Replication / ETL inputs** — columns populated by external systems where the application must treat them as inputs only. |
| 16 | + |
| 17 | +Every adopter today either hand-codes per-field read-only-ness in `partial class`-style escape hatches or accepts that codegen emits writable setters for fields that must not be written. Adding a single logical attr — `@readOnly: true` — closes the gap cleanly. |
| 18 | + |
| 19 | +## Layer placement (ADR-0013 litmus test) |
| 20 | + |
| 21 | +`@readOnly` changes the field's **native binding** in every language (presence or absence of a setter). That is a *logical* concern, not a physical one. It is **not** introspectable from `information_schema` — DB introspection sees a column with a default expression but cannot know whether the application is supposed to treat it as read-only. The application's contract is the source of truth. |
| 22 | + |
| 23 | +Therefore: **`@readOnly` lives in `core/field/field-schema.ts`**, registered on `commonFieldAttrs` alongside `@required` / `@unique` / `@default` / `@objectRef`. It is *not* a `dbProvider` attribute. |
| 24 | + |
| 25 | +## Decision |
| 26 | + |
| 27 | +Add a single boolean attribute `@readOnly` to every `field.<subtype>`. Default: `false` (writable; existing behavior preserved). |
| 28 | + |
| 29 | +### Schema declaration |
| 30 | + |
| 31 | +In `server/typescript/packages/metadata/src/core/field/field-schema.ts`, append to `commonFieldAttrs`: |
| 32 | + |
| 33 | +```typescript |
| 34 | +{ |
| 35 | + name: FIELD_ATTR_READ_ONLY, |
| 36 | + valueType: ATTR_SUBTYPE_BOOLEAN, |
| 37 | + required: false, |
| 38 | + description: |
| 39 | + "When true, the field is read-only: codegen emits no setter / writable property, " + |
| 40 | + "and the persistence layer skips the column on INSERT / UPDATE. The value is " + |
| 41 | + "populated by the database (computed column, default expression, trigger), by " + |
| 42 | + "replication, or by another external owner. Conceptually orthogonal to @required.", |
| 43 | +} |
| 44 | +``` |
| 45 | + |
| 46 | +Constant in `field-constants.ts`: |
| 47 | + |
| 48 | +```typescript |
| 49 | +/** Field-level read-only flag. When true, codegen emits no setter and persistence |
| 50 | + * skips the column on writes. Default false. */ |
| 51 | +export const FIELD_ATTR_READ_ONLY = "readOnly"; |
| 52 | +``` |
| 53 | + |
| 54 | +### Semantics |
| 55 | + |
| 56 | +- `@readOnly: true` → the field is read-only from the application's perspective. |
| 57 | + - Codegen emits a getter only (no setter / no writable property). |
| 58 | + - The persistence layer omits the column from generated INSERT and UPDATE statements. |
| 59 | + - Zod / Pydantic / class-validator schemas mark the field read-only on input variants (create / update) and present on output variants (read). |
| 60 | +- `@readOnly: false` (or omitted) → writable. Existing behavior. No change. |
| 61 | +- The attribute applies to fields of any subtype (`field.string`, `field.int`, `field.timestamp`, ...). It does not interact with `field.object @storage` differently from other subtypes — a read-only object-field is read-only as a whole. |
| 62 | + |
| 63 | +### Cross-attribute validation rules |
| 64 | + |
| 65 | +1. **`@readOnly: true` with `identity.primary @generation: "assigned"`** is rejected: the application has no path to populate the identity value (no setter; not generated; not defaulted). Emit `ERR_READONLY_ASSIGNED_PRIMARY`. |
| 66 | + |
| 67 | +2. **`@readOnly: true` inside `object.value`** is currently a no-op (value-objects do not expose mutability semantics at the field level). The loader accepts the metadata without error and emits `WARN_READONLY_VALUE_OBJECT` for visibility — codegen may use the attribute for record / struct treatment in some languages (e.g., Kotlin `val` instead of `var`) but the persistence implication does not apply. |
| 68 | + |
| 69 | +### Inheritance / `extends:` interaction |
| 70 | + |
| 71 | +Inherited fields carry their declared `@readOnly` value through `extends:` resolution. A concrete subtype may **upgrade** a writable inherited field to read-only (`@readOnly: true`), but **cannot downgrade** a read-only inherited field to writable. Attempting `@readOnly: false` on a field whose base declares `@readOnly: true` produces `ERR_READONLY_DOWNGRADE` at the effective-tree resolution step. |
| 72 | + |
| 73 | +Special case for view projections: when an `object.entity` extends a writable base AND its `source.rdb @kind` is in `SOURCE_READ_ONLY_KINDS`, the source's read-only-ness wins entity-wide. Every inherited field becomes read-only at the persistence layer regardless of per-field `@readOnly`. The codegen contract is "max-restrictive read-only-ness wins." |
| 74 | + |
| 75 | +## Per-port codegen mapping |
| 76 | + |
| 77 | +| Port | Emission for `@readOnly: true` | |
| 78 | +|---|---| |
| 79 | +| **TypeScript** | `readonly` property on the entity type; Zod schema's `omit(...)` removes the field from the create/update input variants; Drizzle insert/update column lists exclude it. | |
| 80 | +| **Java** | Private field + public getter only (no setter); Lombok `@Setter` excludes the field; JPA `@Column(insertable = false, updatable = false)`. | |
| 81 | +| **Kotlin** | `val` instead of `var` in the data class; Exposed table omits from `insert`/`update` builders. | |
| 82 | +| **C#** | `public T Field { get; private set; }`; EF Core `.Property(x => x.Field).ValueGeneratedOnAddOrUpdate().Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore)`. | |
| 83 | +| **Python** | `@property` getter only (no setter); Pydantic `Field(frozen=True)` or `Final[...]`; SQLAlchemy column marked `Column(..., readonly=True)` or excluded from mutation paths. | |
| 84 | + |
| 85 | +### `migrate-ts` impact |
| 86 | + |
| 87 | +**No DDL emission.** `@readOnly` is a runtime / native-binding concern, not a DDL concern. The underlying mechanism (computed column, trigger, replication target, etc.) is declared independently — by hand-coded migration SQL, by a future `@externalSql` reference, or by an `@dbColumnType` physical attribute. `migrate-ts` does not infer DDL from `@readOnly`. |
| 88 | + |
| 89 | +**Optional verify-time warning** (post-implementation polish): `migrate-ts verify` may emit `WARN_READONLY_NO_GENERATOR` when a column declared `@readOnly: true` has neither a `GENERATED ALWAYS` clause, nor a `DEFAULT` expression, nor a documented external owner. This is a quality-of-life check, not a load-time error. |
| 90 | + |
| 91 | +## Conformance fixtures |
| 92 | + |
| 93 | +Under `fixtures/conformance/`: |
| 94 | + |
| 95 | +1. **`field-readonly-basic/`** — single field with `@readOnly: true`; canonical round-trip preserves the attr. |
| 96 | +2. **`field-readonly-inherited/`** — concrete entity extends an abstract base; `@readOnly` carried through inheritance without re-declaration on the subtype. |
| 97 | +3. **`field-readonly-upgrade-on-subtype/`** — subtype upgrades a writable inherited field to `@readOnly: true`; effective tree shows read-only. |
| 98 | +4. **`field-readonly-on-view-projection/`** — entity with `source.rdb @kind: "view"` declaring `@readOnly: true` on every projection column. Legal; canonical round-trip preserves. |
| 99 | +5. **`error-field-readonly-downgrade/`** — subtype attempts `@readOnly: false` over a parent's `@readOnly: true`. Expect `ERR_READONLY_DOWNGRADE`. |
| 100 | +6. **`error-field-readonly-assigned-primary/`** — `identity.primary @generation: "assigned"` on a `@readOnly: true` field. Expect `ERR_READONLY_ASSIGNED_PRIMARY`. |
| 101 | + |
| 102 | +Fixture format follows the existing `field-object-storage-*` precedent (`input/meta.demo.json` + `expected.json`). |
| 103 | + |
| 104 | +## Effort estimate |
| 105 | + |
| 106 | +- TS reference (constants + schema + loader validation + 6 fixtures): ~1 day. |
| 107 | +- Per-port fanout (Java / Kotlin / C# / Python) — register the attr, wire codegen, run shared corpus: ~1 day each, parallel. |
| 108 | +- Total elapsed if ports fan out in parallel: **~2-3 days.** |
| 109 | + |
| 110 | +Smallest of the four FRs in the current metamodel batch. |
| 111 | + |
| 112 | +## Out of scope |
| 113 | + |
| 114 | +- The DDL machinery that *makes* a column read-only (computed column generation, trigger emission, replication target binding). That is downstream of this metadata attribute and lives in dialect-specific migration code or hand-written SQL. |
| 115 | +- Auto-detection of read-only-ness from introspection. DB introspection cannot reliably distinguish "computed by database" from "computed once at insert" — the application's contract is the source of truth, not the schema. |
| 116 | +- Method-level read-only semantics (e.g., a method that doesn't mutate). Not a field concern. |
| 117 | + |
| 118 | +## Cross-references |
| 119 | + |
| 120 | +- ADR-0013 — logical/physical placement (this addition is logical; lives in `core/field/`). |
| 121 | +- ADR-0002 — open-closed; this is one attr declaration + one canonical-serializer entry, no central edits. |
| 122 | +- Conformance corpus README at `fixtures/conformance/README.md` for fixture format. |
0 commit comments