Metadata Annotations
Requires
metadata-starteras a dependency of your app for these annotations to take effect.softa-ormdefines the annotations;metadata-startercontains the scanner and checker that read them and reconcile withsys_*. Withoutmetadata-starterthe annotations exist on your classes but no scanner consumes them —sys_*rows are never written and no DDL is generated.
Softa describes models, fields, option sets, option items, and indexes
through Java annotations on the entity classes. A boot-time scanner reads
these annotations, reconciles them with the sys_* catalog tables managed
by metadata-starter, and (for packages in scanner-scope) converges the
physical schema to the annotations — declared changes and hand-made drift
alike, so a restart always ends with schema ≡ annotations for everything the
scope owns.
Five annotations — @Model / @Field / @Index live in
io.softa.framework.orm.annotation; @OptionSet / @OptionItem live in
io.softa.framework.base.annotation (so framework-level enums in softa-base
can carry them without a module cycle):
| Annotation | Target | sys_* table written | Purpose |
|---|---|---|---|
@Model | class | sys_model | Describes an entity (table, business key, multi-tenancy, soft delete, etc.) |
@Field | field | sys_field | Describes a column (label, type, length, required, relations, etc.) |
@OptionSet | enum class | sys_option_set | Marks an enum as a managed option set |
@OptionItem | enum constant | sys_option_item | Per-constant display attributes |
@Index | class (@Repeatable) | sys_model_index | Declares a database index |
@Data
@EqualsAndHashCode(callSuper = true)
@Model(
label = "Customer",
businessKey = {"code"},
description = "Customer master"
)
@Index(indexName = "uk_customer_code", fields = {"code"}, unique = true)
@Index(fields = {"status", "createdTime"})
public class Customer extends AuditableModel {
@Field(label = "ID")
private Long id;
@Field(label = "Customer Code", required = true, length = 32)
private String code;
@Field(label = "Customer Tier")
private CustomerTier tier; // enum → FieldType.OPTION (inferred)
}
@OptionSet(label = "Customer Tier")
public enum CustomerTier {
@OptionItem(label = "VIP Gold") GOLD("g"), // explicit: "VIP Gold" ≠ humanize("GOLD")
SILVER("s"); // bare: label defaults to humanize("SILVER") = "Silver"
@JsonValue private final String code; // itemCode = @JsonValue
CustomerTier(String code) { this.code = code; }
}Inference rules (no annotation needed)
| Concept | Derived from | Override |
|---|---|---|
modelName | class simple name | — (no override) |
fieldName | Java field name | — (no override) |
optionSetCode | enum class simple name | — (no override) |
itemCode | @JsonValue field value (fallback enum.name()) | — (no override) |
tableName | snake_case(modelName) | @Model.tableName |
columnName | snake_case(fieldName) | @Field.columnName |
fieldType | Java type via TypeInference (e.g. String→STRING, enum→OPTION, List<enum>→MULTI_OPTION, @Model POJO→MANY_TO_ONE, DTOFieldObject POJO→DTO) | @Field.fieldType = FieldType.X (single value, no braces); OPTION / MULTI_OPTION cannot be written explicitly; TEXT (unbounded long text) is never inferred — declare it explicitly on a String field |
index indexName | idx_<table>_<col>... / uk_<table>_<col>... for unique | @Index.indexName |
@Model ↔ SysModel
@Model attribute | Type | Default | SysModel column | Notes |
|---|---|---|---|---|
| (class simple name) | — | — | modelName | inferred, no override |
label | String | "" | label | empty → humanized class name (DeptInfo→“Dept Info”); i18n translations override by id |
renamedFrom | String | "" | renamedFrom | immediately-prior model name for a rename (single-step, no chain) — see “Renames” below |
tableName | String | "" | tableName | empty → snake_case(modelName) |
description | String | "" | description | ≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc |
displayName | String[] | {} | displayName | list-display defaults |
searchName | String[] | {} | searchName | search-field defaults |
defaultOrder | String[] | {} | defaultOrder | e.g. "createdTime:desc" |
softDelete | boolean | false | softDelete | requires a deleted field; every read appends deleted = false (bypass via FilterControl.bypassSoftDelete()), and the starting value false is materialized into sys_field.default_value so the column DDL carries DEFAULT FALSE |
activeControl | boolean | false | activeControl | requires an active field; every read appends active = true unless the caller’s own filters name active (or FilterControl.bypassActiveControl() is set), so disabling retires a row from reads without deleting it; the starting value true is materialized into sys_field.default_value so the column DDL carries DEFAULT TRUE. Mutually exclusive with timeline (boot-rejected) |
timeline | boolean | false | timeline | effective-dated rows (see Timeline Model); mutually exclusive with activeControl — express period state as a versioned business field and terminate via setEndDate |
idStrategy | IdStrategy | DB_AUTO_ID | idStrategy | |
storageType | StorageType | RDBMS | storageType | |
versionLock | boolean | false | versionLock | optimistic-lock column; requires a version field, stamped onto every insert, with the starting value 0 materialized into sys_field.default_value so the column DDL carries DEFAULT 0 |
multiTenant | boolean | false | multiTenant | requires a tenantId field on the class |
copyable | boolean | true | copyable | false ⇒ copy APIs reject the model; UI hides Duplicate |
projection | boolean | false | projection | true ⇒ read-only model over a table it does NOT own (another model’s table, or one created externally, e.g. by a BI pipeline). No DDL is ever generated for it; write APIs reject it; @Index on it is boot-rejected; RDBMS only. Every non-projection RDBMS model owns its table exclusively — two owners on one tableName fail at boot |
dataSource | String | "" | dataSource | empty → primary datasource |
businessKey | String[] | {} | businessKey | composite supported |
partitionField | String | "" | partitionField | |
| (scanner sets) | — | — | appCode | always set by scanner / Studio |
| (DB auto) | — | — | id | primary key |
Audit fields (createdTime / createdBy / createdId / updatedTime /
updatedBy / updatedId) come from AuditableModel and are not declared
via @Field — they are auto-injected by DdlGenerator when the class
extends AuditableModel.
@Field ↔ SysField
@Field attribute | Type | Default | SysField column | Notes |
|---|---|---|---|---|
| (Java field name) | — | — | fieldName | inferred, no override |
| (Java type) | — | — | fieldType | inferred via TypeInference |
label | String | "" | label | empty → humanized field name (deptId→“Dept Id”); i18n translations override by id |
renamedFrom | String | "" | renamedFrom | immediately-prior field name for a rename (single-step) — see “Renames” below |
description | String | "" | description | ≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc |
fieldType | FieldType[] | {} | fieldType | single value, no braces (e.g. fieldType = FieldType.MULTI_FILE); OPTION/MULTI_OPTION cannot be written explicitly |
columnName | String | "" | columnName | empty → snake_case(fieldName) |
length | int | 0 | length | 0 → type default: STRING/OPTION 64, MULTI_STRING/ORDERS 256, DOUBLE 24 (measurements), BIG_DECIMAL 32 (money); declare explicitly for anything else. On TEXT fields length is optional — purely an app-level guard (the column is unbounded). Legacy: MySQL renders STRING length > 16383 as TEXT (64KB bytes; prefer fieldType = TEXT) |
scale | int | 0 | scale | 0 → type default: DOUBLE 2, BIG_DECIMAL 8 (DECIMAL scale) |
required | boolean | false | required | NOT NULL constraint |
readonly | boolean | false | readonly | UI hint |
translatable | boolean | false | translatable | i18n-aware column |
copyable | boolean | true | copyable | false ⇒ value not carried over by copyById (business keys, credentials, runtime state) |
unsearchable | boolean | false | unsearchable | excluded from default search |
computed | boolean | false | computed | requires expression |
expression | String | "" | expression | AviatorScript |
dynamic | boolean | false | dynamic | not physically stored |
encrypted | boolean | false | encrypted | at-rest encryption |
autoSequence | boolean | false | auto_sequence | auto-fill from a sequence on INSERT when blank; STRING only (not dynamic/computed/id, RDBMS only); pairs with a sys_sequence row "<Model>.<field>" (missing row = insert fails, fail-closed). + readonly = strict system numbering (caller values rejected); without = caller values trusted (imports). Never carried on copy |
maskingType | MaskingType[] | {} | maskingType | single element |
defaultValue | String | "" | defaultValue | |
relatedModel | Class<?> | Void.class | relatedModel | Class ref (compile-checked), e.g. Foo.class; Void.class → inferred from POJO type; required for Long FK. Use relatedModelName (String) for cross-module/dynamic models |
relatedModelName | String | "" | relatedModel | String fallback to relatedModel (cross-module/dynamic) |
relatedField | String | "" | relatedField | TO_ONE: always id — leave empty (a non-id value is rejected at boot; to store a business code make the related model code-as-id). ONE_TO_MANY: names the child FK column |
onDelete | OnDelete[] | {} | on_delete | TO_ONE FK delete strategy: RESTRICT / CASCADE / SET_NULL; {}/unset = KEEP (default — do nothing). App-level (no DB FK). See “Delete strategy” below |
joinModel | Class<?> | Void.class | joinModel | M2M join model class; joinModelName (String) fallback |
joinLeft | String | "" | joinLeft | |
joinRight | String | "" | joinRight | |
cascadedField | String | "" | cascadedField | dotted path, e.g. "owner.name" |
filters | String | "" | filters | filter expression for relations |
widgetType | WidgetType[] | {} | widgetType | single-element override |
| (scanner sets) | — | — | modelName | from enclosing @Model class |
| (scanner sets) | — | — | optionSetCode | derived from enum type when fieldType is OPTION/MULTI_OPTION |
| (scanner sets) | — | — | appCode / id | |
| (FK fixup post-init) | — | — | modelId | |
| (system-computed) | — | — | relatedFieldType | physical type of a TO_ONE FK column, mirrored from the referenced model’s id (+ mirrored length/scale) at reconciliation time; never declared on @Field |
(not exposed via @Field) | — | — | hidden | UI-only flag set via Studio |
Copy field-selection contract (applies regardless of the copyable flag): ONE_TO_ONE FKs are always excluded — copying one would make two rows share an exclusively-owned related row, corrupting the 1:1 (or hard-failing on its unique index); dynamic fields (ONE_TO_MANY / MANY_TO_MANY / computed / cascaded) are excluded because they are not stored columns; MANY_TO_ONE stays copyable — a shared reference is exactly its semantics. Historical trap: the nonCopyable → copyable rename was done as a migration (V6), NOT via renamedFrom, because the rename inverts the value’s meaning — a value-preserving rename would have carried wrong values.
Delete strategy (onDelete)
On a MANY_TO_ONE / ONE_TO_ONE FK, onDelete declares what happens to the referencing rows when
the referenced (“One”) row is deleted. Enforced application-level in ModelServiceImpl.deleteByIds — no
physical DB FOREIGN KEY ... ON DELETE is ever emitted. Why app-level and never a real DB FK: soft
delete is an UPDATE, invisible to a DB ON DELETE (the FK would simply never fire); a DB cascade
bypasses permissions, change logs, audit stamping, soft-delete conversion and tenant scoping; a DB FK
cannot express “count only deleted=false referrers”, “block regardless of tenant”, or “null only on
hard delete”; and physical FK constraints sit outside the annotation-driven DDL governance (the
scanner neither declares nor manages them). Strategies:
RESTRICT— block the delete if any live (deleted=false) referrer exists.CASCADE— delete the referrers in the same transaction (each follows its own soft/hard delete). Rejected at boot if a soft-delete One would cascade to a hard-delete Many (a recoverable parent must not irreversibly delete children — make the Many soft-delete too, or use RESTRICT/SET_NULL).SET_NULL— null the referrer FK; only on a hard delete of the One (no-op on soft delete, so a restore still resolves the link). Requires a nullable FK (required = false).- unset (
{}/on_deleteNULL) = KEEP (default) — the framework does nothing.
CASCADE soft/hard-delete matrix — the cascade on each Many follows the Many’s own delete mode (not the One’s); the one unsafe combination is rejected at boot:
| One (referenced / parent) | Many (referrer / child) | CASCADE result |
|---|---|---|
| soft-delete | soft-delete | Many soft-deleted (both recoverable) |
| soft-delete | hard-delete | rejected at boot — a recoverable parent must not irreversibly delete children |
| hard-delete | soft-delete | Many soft-deleted |
| hard-delete | hard-delete | Many hard-deleted |
A CASCADE from a shared (non-multi-tenant) parent to a multi-tenant child is likewise rejected at
boot — one delete would cascade across all tenants (use RESTRICT).
Runtime safety — a CASCADE / SET_NULL affecting more than MAX_BATCH_SIZE referrers per cascade
level is rejected: referrerIds fetches at most MAX_BATCH_SIZE + 1 ids in one LIMIT-ed query, so an
over-limit delete fails fast without loading the full set (bounded memory, no extra count). Large
deletes are chunked to DEFAULT_BATCH_SIZE to bound the SELECT/DELETE statement + IN-clause size (same
transaction — chunking bounds statement size, not lock duration).
For a OneToMany “delete parent → delete children”, put CASCADE on the child’s back-reference FK
(the FK is the single source of truth; onDelete is not declared on ONE_TO_MANY).
Boot-time guards (fail-fast): onDelete is valid only on TO_ONE; SET_NULL requires a nullable FK; a
cyclic / self-referential CASCADE is rejected (delete such hierarchies — org trees, BOM, category
trees — in application code); a CASCADE chain deeper than MAX_CASCADE_DEPTH models is rejected
(bounds recursion; the error names the full chain); and a CASCADE from a soft-delete parent to a
hard-delete child, or from a shared parent to a multi-tenant child, is rejected (see the matrix
above).
A timeline target is allowed: the inbound-FK strategy fires on entity deletion (deleteByIds,
which removes all slices of the logical id — referencing FKs store that logical id, so RESTRICT counts /
CASCADE deletes / SET_NULL nulls by it, no effective-date resolution involved); slice-level
deleteBySliceId keeps the entity alive and deliberately does not trigger it.
Field-level overview for product/metadata authors: onDelete in Field metadata.
@OptionSet ↔ SysOptionSet
@OptionSet attribute | Type | Default | SysOptionSet column | Notes |
|---|---|---|---|---|
| (enum simple name) | — | — | optionSetCode | inferred, no override |
label | String | "" | label | display label; empty → humanized enum name (TenantStatus→“Tenant Status”) |
renamedFrom | String | "" | renamedFrom | immediately-prior option-set code for a rename (single-step) |
description | String | "" | description | ≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc |
| (scanner sets) | — | — | appCode / id | |
| (Studio toggle) | — | — | active / optionItems | runtime aggregation |
@OptionItem ↔ SysOptionItem
@OptionItem attribute | Type | Default | SysOptionItem column | Notes |
|---|---|---|---|---|
(@JsonValue field value on enum) | — | — | itemCode | fallback to enum.name() when no @JsonValue |
| (enclosing enum simple name) | — | — | optionSetCode | inferred |
label | String | "" | label | defaults to humanized constant name (MULTI_FILE→“Multi File”); declare explicitly to customize. Omit when it equals the humanized name (and omit the whole @OptionItem if nothing else remains) |
renamedFrom | String | "" | renamedFrom | immediately-prior item code for a rename (single-step) |
description | String | "" | description | ≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc |
sequence | int | -1 | sequence | -1 → use ordinal() + 1 |
parentItemCode | String | "" | parentItemCode | hierarchy |
itemTone | OptionItemTone[] | {} | itemTone | single element |
itemIcon | OptionItemIcon[] | {} | itemIcon | single element |
| (scanner sets) | — | — | appCode / id / optionSetId | |
| (Studio toggle) | — | — | active |
@Index ↔ SysModelIndex
@Index is @Repeatable — stack multiple declarations on one @Model class.
@Index attribute | Type | Default | SysModelIndex column | Notes |
|---|---|---|---|---|
| (enclosing class) | — | — | modelName | inferred |
indexName | String | "" | indexName | empty → auto-derived idx_<table>_<col>... / uk_<table>_<col>... for unique; index names are globally unique (≤ 60 chars, boot-enforced) |
fields | String[] | required | indexFields | camelCase Java field names, not column names |
unique | boolean | false | uniqueIndex | |
message | String | "" | message | unique-only: user-facing message shown on a uniqueness violation (has its own i18n key) |
| (scanner sets) | — | — | appCode / id | |
| (FK fixup post-init) | — | — | modelId |
Note: @Model.businessKey does not auto-create a UNIQUE index.
Multi-tenant models typically want UNIQUE (tenant_id, businessKey...)
which has tenant-aware semantics not expressible by @Index alone —
declare such indexes explicitly:
@Index(fields = {"tenantId", "code"}, unique = true)Renames (renamedFrom)
The scanner’s diff is keyed by modelName / fieldName / optionSetCode /
itemCode, so an undeclared rename looks like “drop old + add new” — and
under an active scanner-scope the convergence pass executes exactly that: the
new column is added empty, and the old column, no longer declared by anything,
is dropped together with its data in the same boot. Nothing arrives in the
new column.
Declare the immediately-prior name instead:
@Model(renamedFrom = "OldCustomer") // model rename
public class Customer extends AuditableModel {
@Field(renamedFrom = "customerName") // field rename
private String name;
}The DiffEngine then pairs the two sides into a single rename modification,
auto-executes CHANGE COLUMN (field) / ALTER TABLE … RENAME TO (model), and
updates the sys_* row in place (id preserved) — data is carried, not
divorced. A model rename cascades onto its fields and indexes, so it shows no
field churn. @OptionSet / @OptionItem support the same attribute.
Rules and guards:
renamedFromis a single String — the immediately-prior name only (single-step, no chain). A skipped-version chain needs a manual migration.- Declaring a prior name that is still a live field/model, or two siblings claiming the same prior name, fails at parse time.
- “Both the new and the prior name already exist” fails fast — resolve the half-applied rename manually.
- An
@OptionItemcode rename that also carries business-data UPDATEs still needs a hand-written migration.
scanner-scope (which packages the scanner manages)
scanner-scope is a list of regex patterns full-matched against each
@Model / @OptionSet class’s package name. "*" (sole entry) = all
packages; empty / unset = manage nothing. It should never be non-empty in
production — in production, Studio / connector publish applies the
app-scoped design catalog instead.
# application-dev.yml
system:
metadata:
scanner-scope:
- "*" # manage every package; on a shared dev DB, narrow to
# your own packages, e.g. ["io\\.acme\\.app.*"]system.metadata.scanner-scope | Scanner runs | DDL execution | Drift detection |
|---|---|---|---|
["*"] | Boot-time, eager, all packages | Physical convergence (see below): every owned table converges to its annotations on every boot — CREATE / ADD / MODIFY (narrowing included) / declared RENAME, plus DROP of undeclared columns and indexes | Code-less catalog roots named in a WARN with copy-paste SQL; the drift audit reports the residual (projections, undeclared tables) |
["io\\.acme\\.foo.*", …] | Boot-time, in-scope packages only | Same convergence, in-scope models only — out-of-scope tables are never touched | n/a |
| empty / unset (default, prod) | n/a | n/a | MetadataAnnotationChecker runs post-boot on a virtual thread; logs WARN if code-vs-DB drift detected — report-only, nothing executes |
On a shared dev database, give each developer a narrow scope (their own
packages) so the scanner only reconciles — and converges — the Java packages
they are actively changing. Scope is per-package, not per-class; app identity
is still app_code, and physical table-name collisions remain a
database-level concern.
Catalog row policy
The catalog is an aggregate: sys_model / sys_option_set are the roots, sys_field / sys_model_index / sys_option_item their attributes.
| Change | Applied |
|---|---|
| Root added / modified | ✅ |
| Attribute added / modified / removed, on a root whose class is present | ✅ — the annotations own the root’s attribute set |
Root removed (a catalog row with no @Model / @OptionSet class) | ❌ under every scope, ["*"] included — the root and its attribute rows are left untouched; ["*"] logs a WARN naming them with copy-paste DELETE SQL |
A code-less root is a first-class state: Studio no-code and seed-authored models never have a Java class, and nothing in the catalog records row ownership — so “orphan” and “deliberately code-less” cannot be told apart, and auto-deleting would silently destroy hand-authored definitions on every boot. Note the contrast with the physical schema below: an undeclared column on a table the scope owns has no legitimate author (the owner’s annotations are its single source of truth) and is converged away, while a code-less root may be someone’s deliberate definition and is only ever named in the WARN.
DDL execution policy (physical convergence)
With an active scanner-scope, the physical table of every in-scope owned model is a pure
function of the annotations — every boot converges it, declared changes and hand-made drift
alike:
| State (annotation vs physical) | DDL | Executed? |
|---|---|---|
New @Model / table physically missing | CREATE TABLE (with inline indexes); a pre-existing table is adopted column-by-column instead | ✅ |
New @Field / declared column physically missing | ADD COLUMN | ✅ |
Changed @Field attribute (type / length / required / default / comment) | MODIFY COLUMN to the declared shape | ✅ |
| Physical type/width mismatch — widen, narrow, incomparable | MODIFY COLUMN to the declared shape — the declaration is the truth, and a non-empty scanner-scope is by definition non-production | ✅ |
Removed @Field / undeclared physical column | DROP COLUMN | ✅ |
| Undeclared physical index | DROP INDEX (primary-key backing indexes excluded) | ✅ |
New @Index / declared index physically missing / definition changed | ADD INDEX / rebuild (DROP + ADD) | ✅ |
Removed @Model | DROP TABLE | ❌ — the code-less root keeps its rows and its table; the ["*"] WARN prints the cleanup SQL |
Bare tableName change while the old table physically exists | — | ❌ boot fails with instructions — creating the new table would silently divorce the data, and the planner never guesses |
| Whole undeclared tables; anything on a projection | — | ❌ untouched — ownership cannot be proven (another app_code, a legacy table) / the table belongs to the owner; the drift audit is the reporting channel |
If physical introspection fails, the boot degrades to conservative metadata-only
planning: additive changes and declared renames auto-execute; every destructive verb
defers to a warn-only copy-paste SQL block — without facts, drift and intent are
indistinguishable. Destructive convergence can never reach production, because production
runs the empty scope (checker-only): the gate is the existing scanner-scope posture, not
a separate switch.
Projection models are outside this table entirely: a model declared @Model(projection = true) (a read-only model over a table it does not own — another model’s table, or one created externally, e.g. by a BI pipeline) generates no DDL for any change. Its sys_* rows still reconcile, but the table’s shape belongs to its owning model or the external process. One table has ONE non-projection owner — a second owner fails at boot, which makes a fresh-database bootstrap deterministic (exactly one CREATE per table) and turns an accidental tableName collision into a boot error instead of a silent table merge. The physical drift audit checks a projection one-way (its declared columns must exist; the owner’s other columns/indexes are never reported as undeclared), and a physically missing projection table logs an ERROR — never a boot failure, never auto-created. Convention for in-app sharing: repeat the owner’s column declarations verbatim for the columns the projection exposes, and declare everything else dynamic.
Catalog self-bootstrap (the sys_* tables’ own schema)
The five boot-read catalog tables (sys_model, sys_field, sys_option_set,
sys_option_item, sys_model_index) have no row-level “last applied state” —
the rows recording every other model’s state live inside them. On every boot
with a non-empty scanner-scope, the scanner therefore reconciles them
physically, from their own annotations, before the strict catalog read:
- table missing →
CREATE TABLE— a fresh, empty database bootstraps with no baseline SQL at all; - column missing →
ADD COLUMN— a catalog-column addition in a new framework version converges on existing databases without a hand-written migration; - declared
renamedFromwith the prior column present →CHANGE COLUMN(data carried); both old and new present → boot fails with instructions; - physically narrower than declared (bounded widths only) → widening
MODIFY COLUMN; anything wider / incomparable / undeclared is left untouched by this stage — it runs before the diff exists and possibly under a narrow scope that does not manage the catalog. When the catalog packages are in scope, the main convergence pass later in the same boot eliminates those like any other in-scope drift; otherwise the physical drift audit reports them.
The whole boot DDL window (catalog reconcile → strict read → diff → DDL → row
writes) is serialized across instances by a database session lock (MySQL
GET_LOCK / PostgreSQL advisory lock, 60s wait budget), so replicas booting
the same database never race their DDL.
What still needs a hand-written migration: backfill UPDATEs that give an
added column real values on rows the scanner does not manage, destructive
changes on out-of-scope tables, and environments running with an empty
scanner-scope — there nothing auto-applies, catalog included.
Metadata identity (app_code)
There is no ownership tier column on the sys_* catalog. The annotation
lane and the Studio no-code lane reconcile the same rows, matched by
business key (modelName / fieldName / optionSetCode / itemCode, plus
renamedFrom) — a same-key row is updated in place, never duplicated per
channel.
Every runtime declares system.app-code in application.yml (mandatory when
metadata-starter is active; fail-fast at boot). All swept sys_* rows carry
app_code, stamped server-side on every write path (scanner, Studio
envelope, plan/apply) — wire values are never trusted. Signed Studio calls
carry the target appCode and the runtime rejects mismatches. Multiple apps
can safely share one database: rows are matched per app_code, so shared
databases never cross-link catalogs.