diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md
index 0c7579f6472..2f61f5358f4 100644
--- a/components/engine/engine-intent/CLAUDE.md
+++ b/components/engine/engine-intent/CLAUDE.md
@@ -471,7 +471,7 @@ Semantics worth knowing:
- **`history: true` on an entity = the shadow change trail (#6715).** `audit: true` keeps only the LAST writer and time, in four columns of the row itself; a regulated domain has to answer *what changed, from what to what, by whom, when* for every write, and that was hand-written or skipped. `history: true` gives the entity a sibling **`
_HISTORY`** shadow table (the `_LANG` pattern: emitted by `application.schema.template` off the EDM `history="true"` attribute `EdmIntentGenerator` writes) shaped `GUID, Id, Operation, Property, OldValue, NewValue, ChangedAt, ChangedBy, Source`, and the generated repository appends **one row per property whose value actually changed** on every write path it owns — create (`null -> value`), update, `updateWithoutEvent`, the targeted `updateProperty`/`updateProperties` (whose override is now gated on `history` too — the base ones write the column directly and would leave no trace), `recalculate` (it deliberately calls the BASE targeted write, so it records for itself) and delete (`value -> null`). The writer is the SDK `org.eclipse.dirigible.sdk.db.History` (api-modules-java, `Translator`'s sibling: plain JDBC, quoted exact-case identifiers, values stringified and truncated at 4000). Four decisions worth keeping: **(1) `Source` is `USER` vs `SYSTEM`** — the user-facing paths record USER, every targeted/system write records SYSTEM, because once a roll-up total and a person's edit land in the same column nothing downstream can tell them apart. **(2) The before-image is read through `super.findById`, never the class's own override** — on a multilingual entity the override overlays the caller's language, and a translated value diffed against the stored one reports an edit nobody made. **(3) Decimals are compared by `compareTo`, not `equals`** — a recomputed `2.0` against a stored `2.00` is the same amount, and treating it as a change fills the trail with noise. **(4) The tracked set excludes the primary key and the audit columns** (they say exactly what the row itself says). Read-only end to end: `GET /{id}/history` on the entity's own controller (404 on an unknown row — never an empty trail a caller could read as "nothing happened"), rendered as a **History** card in the manage form's and the document's right sidebar; there is no create/update/delete verb on the shadow table anywhere, which is what makes it append-only *by construction* rather than by policy. Two interactions are deliberately specified: the **scoped surfaces get no history endpoint at all** (a `my`/`partner` controller strips `sensitive:` fields from its responses, so handing it a trail carrying those fields' old and new values would leak exactly what the scoping hides — when a scoped panel is wanted it arrives WITH its per-property filter, in one PR), and **CSVIM seeds bypass the repository**, so seeded rows have no history (correct: nobody wrote them). The append happens after the entity write, on its own connection — `JavaEntityStore` commits every operation in its own transaction, so there is no enclosing transaction to join; a failure to append is logged at ERROR and does not fail the already-committed business write. `IntentEmissionCoverageIT` covers all of it (`Entry` for the USER/SYSTEM runtime split, `Claim` for the audit-exclusion and the absent personal endpoint).
- **`multilingual: true` on an entity + `language:`/`file:` seeds + top-level `languages:` = the multi-language data stack.** A multilingual entity's translatable (string-typed) properties may carry per-language values in a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention). `EdmIntentGenerator` emits the EDM `multilingual="true"` entity attribute (the same one the EDM editor writes); the schema template generates the language table from it; the Java DAO template overrides every finder to overlay translations via the SDK `org.eclipse.dirigible.sdk.db.Translator` for the caller's `Accept-Language` (thread-bound `User.getLanguage()`; null → no-op, so listeners/jobs read base values). Translations are authored as **seeds with a `language: bg` code** → `CsvimIntentGenerator` writes them into `
_LANG` (`GUID` auto-numbered, `Language` constant; parser validates the entity is multilingual and row keys are `id` + string/text fields). **Large data sets stay out of the intent**: a seed may reference an authored CSV via `file: data/countries.csv` (exactly one of `file`/`rows`; the path MUST be in a subfolder — root-level `.csv` files are intent-owned and scrubbed) — only the `.csvim` is generated, pointing at the developer-owned file. Top-level `languages: [en, bg]` declares which languages this module PROVIDES translations for (landing on the `.model` root → Harmonia `config.js` `languages`) — it never defines what the stack supports: the **Region & Language** picker always offers the PLATFORM's set (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en`, tenant-overridable via the tenant configuration, served by `platform-core/services/application-languages.js`), backed by the shared `locale` Alpine store (localStorage `codbex.harmonia.language`) whose value the shared fetch client sends as `Accept-Language` on every call — one flag drives the backend translation, and the document Print flow prefers it too. The application shell compares each app's provided set against the platform set and lists gaps as warnings in Settings; untranslated content falls back to the default language. **A report reads the same data, so it reads it in the same language (#6544).** The overlay above is a Java read-time merge, and a report never loads an entity — it is raw SQL over the base tables, so a report column bound to a translatable property used to render the BASE value right next to a list page rendering the translated one (a status column reading `DRAFT` beside a list reading the translated term, from the same record). `ReportIntentGenerator.translate` therefore does the overlay IN THE QUERY: a translated dimension becomes `COALESCE(_LANG."", ."")` over a `LEFT JOIN "
_LANG"` keyed on the base row and `:language` — a bound parameter, never interpolated, which the generated report repository fills from `User.getLanguage()`. Three things make it cheap: it rides entirely on the two round-trip-safe carriers the `.report` already has (a `joins[]` row whose `type` is `LEFT`, and the column's verbatim `expression`), so the **report editor needed no change** and a generated report still opens in the builder's structured mode (`ReportEditorRoundTripTest`); the repository template keys its binding off the QUERY (`#if($query.contains(":language"))`) rather than a model flag, so nothing can go stale and a hand-authored report can use `:language` too; and the target's translatable property set is read from the owner `.model` for a **cross-model** nomenclature (`CrossModelSupport.TargetInfo.translatedProperties`, mirroring the schema template's column rule), which is the common case since nomenclatures usually live in their own module. **Only the SELECT list is overlaid** — `filter:`, the lifecycle `scope:` and the per-column report filters compile against the BASE table, which is exactly why translating a nomenclature can never change what a report matches. Aggregates are not overlaid either (a `min`/`max` over a translated string would pick a different row per language). Caveat (TS parity): editing a record while a non-base language is active saves the displayed (translated) values into the base table — translations are maintained via seeds/DB, not through the generated UI.**A field may declare `translatable: false` (#6545) - the key/label distinction the flag alone cannot make.** On a multilingual entity translatability is derived from the TYPE, so every string property is translated; but some strings are **keys**: the column a posting's `rule.match` selects on, the business key an arrival's `map` `lookup`/`by` resolves a relation by, a code a report filter compares. Translating one breaks the match with NO symptom - the caveat above is the mechanism (the read overlay shows the translated value, saving the row writes it into the BASE column), after which the authored literal matches nothing and the posting simply never fires again. `translatable: false` keeps the field out of the language table entirely, which is the only place a translation can live, so every consumer is fixed by one exclusion: `FieldIntent.hasLanguageColumn()` is now the SINGLE predicate (the schema template's `$property.translatable != "false"` gate mirrors it, and `ReportIntentGenerator.isTranslatable` / `CsvimIntentGenerator`'s seed columns / `CrossModelSupport.translatedProperties` / `AppTestIntentGenerator.firstTranslatableKey` all route through it - they had three copies of the type test between them, which is how a marker could have been honoured in one place and not another). The EDM carries `translatable="false"` on the property only when authored, so a model that does not use it generates byte-identically. Refused at parse: a translation seed row setting a marked field (its CSV column does not exist - a runtime import failure for something the model already states, and this now also covers a CALCULATED string field, which never had a language column either); a `rule.match` on a translated column and a lookup `by:` on a translated field, both naming the marker as the fix; and the marker itself on a non-multilingual entity or a non-string field (authored-but-silently-ignored). Tests: `MultilingualKeyTest` (the refusals + both accepted shapes), `EdmIntentGeneratorTest` (the attribute), `ReportIntentGeneratorTest` (a marked key stays on the base table). Note left standing: `CrossModelSupport.translatedProperties` still does not exclude a `MULTISELECT` property the way the schema template does, so a cross-model report over a `kind: subset` column would COALESCE a language column that was never emitted - unrelated to this change, filed nowhere yet.
- **`label:` on an entity = the stored display name.** `label: "{number} - {date|yyyy MMMM} - {Customer.name}"` synthesizes a read-only `Name` VARCHAR(512) property (`labelNameProperty`) recomputed by the generated repository on save/update/`updateWithoutEvent` (`computeName` in `Repository.java.template` - the system path included, because workflow writes stamp label inputs like the document number; `updateProperty`/`recalculate` deliberately skip it). Tokens parse via `LabelExpression` (shared parser/generator): literals + `{field}` + `{Relation.field}` (ONE hop; `|format` = a `DateTimeFormatter` pattern for temporals) - deeper paths are rejected with a compose hint, since labels COMPOSE by referencing the related entity's generated `Name` (`{ProjectTimesheet.Name}`). Emitted as entity `labelExpression` + `labelParts` (List - .model only); ModelParameterProcessor sets `p.targetRepositoryClass` on every FK and merges it into relation parts + `hasLabel`. `labelFieldName`/`CrossModelSupport.labelField` prefer `Name`, so every dropdown to a label entity is right automatically. Parser rejects a label next to an authored `name` field and any token referencing a `sensitive` field (the Name is visible on the personal surface). Staleness note: a referenced record's rename propagates on the referencing record's next write - bounded, documented.
-- **`identity` / `personal` / `sensitive` = the personal (my) surface.** `identity: ` on the entity representing the person (conventionally the unique e-mail) declares how the logged-in username maps to a record; `personal: true` on a record-owning to-one relation (at most one per entity; target must declare identity - same-model parse-checked, cross-model generation-checked via `TargetInfo.identityProperty`) makes the entity get an ADDITIONAL generated `MyController` (rest-java `EntityMyController.java.template`, `personalModels` collection): reads filtered to the mapped identity record (`Criteria.eq(identityProperty, User.getName())`), owner FK forced server-side on writes, foreign/missing records the same 404, `sensitive: true` fields (never the PK/identity/owner FK) stripped from responses AND ignored on writes - the allow-list is server-side, UI hiding alone would be cosmetic security. Composition children inherit the scope through their DIRECT parent (one hop - `requireMyParent` ancestor guard; deeper chains get no personal surface, documented). The power controller is untouched. Emitted as entity `identityProperty` + FK `relationshipPersonal`/`relationshipIdentityProperty` + field `sensitiveProperty`; ModelParameterProcessor derives `personalProperty`/`personalParent`/`sensitiveProperties`. Design/status: repo-root `PERSONALIZATION_PLAN.md` (phase A; personal UI, Personal Shell, per-user task assignee and collection-driven generation are the later phases).
+- **`identity` / `personal` / `sensitive` = the personal (my) surface.** `identity: ` on the entity representing the person (conventionally the unique e-mail) declares how the logged-in username maps to a record; `personal: true` on a record-owning to-one relation (at most one per entity; target must declare identity - same-model parse-checked, cross-model generation-checked via `TargetInfo.identityProperty`) makes the entity get an ADDITIONAL generated `MyController` (rest-java `EntityMyController.java.template`, `personalModels` collection): reads filtered to the mapped identity record (`Criteria.eq(identityProperty, User.getName())`), owner FK forced server-side on writes, foreign/missing records the same 404, `sensitive: true` fields (never the PK/identity/owner FK) stripped from responses AND ignored on writes - the allow-list is server-side, UI hiding alone would be cosmetic security. Composition children inherit the scope through their DIRECT parent (one hop - `requireMyParent` ancestor guard; deeper chains get no personal surface, documented). The power controller is untouched. Emitted as entity `identityProperty` + FK `relationshipPersonal`/`relationshipIdentityProperty` + field `sensitiveProperty`; ModelParameterProcessor derives `personalProperty`/`personalParent`/`sensitiveProperties`. **`personalReadOnly: true` makes a personal surface see-only**, and it is declarable at either end of the inheritance: alongside `personal: true` it closes the declaring entity's own surface (create/update/delete 403, no write affordance on any my page), and on a CHILD's **composition** relation it closes only that child's inherited surface while the parent stays writable (#7340) - the shape of a header the person authors whose lines only an engine writes (a leave request whose day rows a delegate charges against an entitlement; an expense claim whose reimbursement lines a delegate computes). Coupling the two was a self-grant hole no other key closed: `sensitive:` hides a value and still accepts the write, and `immutableWhen` propagates to the child only for the statuses the parent declares final, while a DRAFT parent is mutable by definition - which is exactly the window the abuse lived in. The child half is refused wherever it would be carried nowhere (`validateInheritedPersonalReadOnly`): on a plain association, on a second composition (only the first is the ownership edge), and on a child whose master has no personal surface to inherit. It reaches the pages as the FK's `relationshipPersonalReadOnly`, which `inheritPersonalScope` ORs into the child's `personalReadOnly`; the master's document page then gates its items panel on the derived `documentItemsReadOnly` (the panel lives on the MASTER's page, so the master's own flag could never express it) and each runtime child panel on its own `readOnly`. Design/status: repo-root `PERSONALIZATION_PLAN.md` (phase A; personal UI, Personal Shell, per-user task assignee and collection-driven generation are the later phases).
- **`visibleTo: [Role, ...]` on a field = role-scoped visibility, enforced where the data leaves the server (#6550).** `sensitive:` hides a field on the PERSONAL surface; nothing scoped one on the main surface, so a salary / rate / margin was visible to every user who could read the entity, and hiding the control would have been cosmetic - the REST response still carried the value. `visibleTo:` is an **allow-list** (never the inverse `hiddenFor:` - a role added to the application later must see nothing until it is listed, and a misspelled role must hide the value, not expose it): the field is stripped from every response and ignored on every write unless the caller holds ONE of the listed roles. Emitted as the model's own per-property **`roleRead` + `roleWrite`** (the same comma-separated pair a hand-modeled `.edm` may carry, so the whole enforcement is the rest-java template's existing `redactRead` / `mergeWritable` / `applyOnCreate` machinery, generalized from one role to any-of); read and write get the SAME list on purpose - a caller who may not see the value must not be able to set it. Enforced on **all three** generated surfaces: the power controller, and the personal / partner ones, where owning the record (or being the partner it belongs to) is not the same as holding the role. Two further exits are closed with it: the **change trail** (`history: true`) drops the entries of a withheld property - it records the before/after of every write - and a derived total fed by a restricted field **inherits its allow-list** (the rollup / `aggregate: true` / `aggregates:` shapes, `propagateRestrictedDerivations`), because a sum of hidden figures is that same figure one entity out. Parser: every listed role must be declared in `permissions:` (an undeclared one is a typo that would hide the field from everybody with nothing anywhere to say so), an empty `visibleTo: []` is refused on the raw tree (the typed mapping cannot tell it from an absent key), and it is refused on the primary key, the `identity` field and the document title - hiding those does not produce a restricted field, it produces a broken page. A **report** over a restricted field is a generation WARNING, not a refusal: a report carries no field-level scoping, so it re-serves the figure to everyone who may open it - legitimate to author (a payroll report over payroll data), never silent. **The UI half is server-driven**: each generated controller exposes `GET .../restricted` answering which properties IT withholds from the caller in front of it (the `/{id}/mutable` precedent), and the generated pages ask it once and leave those columns / inputs / totals / filter + export columns out - the browser never learns a role name, and the redaction on the wire stays authoritative. A page only asks when its entity has such a field (`hasRestrictedFields`), so an application using none of this issues no extra request. Covered by `RoleScopedFieldControllerTemplateIT` (the three controllers rendered through Velocity) and the `visibleTo` assertions in `IntentEmissionCoverageIT`; the runtime redaction itself is not IT-assertable because local basic auth answers `isInRole` true for the test user.
- **`reports[].kind: balance` = the accounting balance report (opening / period / closing).** `kind: balance` + `date:` (the window-driving `date` field — own or one-hop `relation.field`, e.g. `journalEntry.entryDate` on the ledger items) + `debit:`/`credit:` (numeric source amount fields) + `dimensions:` replaces `measures` with six generated totals per dimension row: Opening Debit/Credit (`< :fromDate`), Debit/Credit (the inclusive period), Closing Debit/Credit (`<= :toDate`) — `SUM(CASE WHEN ...)` columns, so opening + period = closing. The window bounds are **declared `.report` `parameters`** (`{name, type: DATE, initial}` — the report editor's existing shape) with all-time defaults (`1900-01-01`/`9999-12-31`), bound by the generated repository's existing `baseParameters` and passed through the controller's GET query params / POST body untouched — `kind: balance` introduced no new backend plumbing, only the first generator that emits `parameters`. The Harmonia report page renders every declared parameter as a first-class input above the filters (date → picker, sent by name in every `/search`/`/count`/`/export` body; empty → the server default) and, for balance, a totals `tfoot` shown only when the whole result fits on one page. Parser (`validateBalanceReport`): date must be `date`-typed (timestamp rejected — a midnight `toDate` would silently drop that day's intra-day entries), debit/credit numeric fields of the source, ≥1 dimension, `measures` forbidden, and date/debit/credit without `kind: balance` is rejected. Only POSTED entries counting is the author's job via `filter:` (e.g. `journalEntry.status == 2`), composable like any report filter. AngularJS report UI ignores the parameters (Harmonia-only pickers, like `where:`).
- **`reports[].kind: statement` = the statutory financial statement (#6909).** A balance sheet or an income statement is a FIXED LINE STRUCTURE where every line is a formula over the chart of accounts - which `kind: balance` cannot express: its output is one row per dimension value, and there is no way to say "this line is accounts 20*+21* netted to the debit side" or "this line is the sum of those two". A statement declares the same ledger inputs (`source` / `date` / `debit` / `credit` / `filter` / `scope`) plus **`account:`** - the string field holding the account CODE, own or one-hop - and **`lines:`**, each line either a LEAF (`accounts:` selector + `measure:`) or COMPUTED (`sum:` / `less:` over other lines' `code`s). Emitted in the ordinary `.report` shape - `Code` / `Label` / `Amount`, the balance report's own `fromDate`/`toDate` parameters - plus a generated `_LINES` `.view` artifact carrying the line classification (#6938, its own entry below), so the whole report pipeline (repository, controller, Harmonia page, security, i18n, dashboard) is reused unchanged. Four things decide the shape and are worth keeping: (a) **the ledger is reduced to one balance per account FIRST**, in a `WITH "ACCOUNT_BALANCES"` CTE, because a `Net` measure (`closingNetDebit` = what is left on the debit side once the account's two sides are netted, per account) has to net BEFORE the line sums - netting after the sum reports gross turnover, and it is exactly what puts a both-type settlement account on the asset side when it is in debit and the liability side when it is in credit; (b) **computed lines are FLATTENED at generation time** into their leaves' own signed terms (rows of the lines view), so every emitted line is one aggregate over the same join and no line waits for another - which is why nesting subtotals needs no recursive SQL and why the parser must reject a reference cycle (it would otherwise recurse until the stack ran out); (c) **the lines are ordered by a grouped-but-not-projected `LINE_ORDINAL`**, since a statement's rows are a structure and its codes sort lexicographically wrong (`A.II` before `A.X`); (d) **no `joins` / `conditions` are emitted on the document** - a statement's joins live inside its own subquery and the report editor's visual builder cannot rebuild a `WITH`, so it deliberately opens **free-style**, which is the safe half of the #6675 round-trip guard rather than an oversight. Selector grammar: `20*` prefix, `4110` exact, `60-69` an inclusive range over equally long code prefixes (`SUBSTRING(code FROM 1 FOR n)` - a plain `BETWEEN '60' AND '69'` would drop `601`, which is the bug this shape exists to avoid); the code charset is closed (letters, digits, dot, underscore) so a quote or a `LIKE` wildcard can never reach the literal. Parser: `dimensions`/`measures` forbidden, unique codes, known measure, resolvable references, no cycle, and a `date`/`debit`/`credit` or `account`/`lines` without the matching kind is refused. `StatementSupport` (measures + selectors) is shared by the parser and the generator, so what validates and what is emitted are one grammar. `StatementReportSqlTest` RUNS the emitted query against a real H2 ledger and reads the figures off - a statement is arithmetic, and a wrong selector or a mis-ordered netting produces well-formed SQL and a plausible wrong number, which no string assertion catches. The same query was verified by hand on PostgreSQL. **Boundary (consistent with #6721):** the statement's NUMBERS are the platform's; the legally mandated print layout stays a hand-authored `.print` over the result.
diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java
index 0bf39a12000..0ba8ba6d990 100644
--- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java
+++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java
@@ -597,6 +597,7 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal
putPartner(fkProperty, relation,
target == null || target.getIdentity() == null ? null : IntentNaming.pascalCase(target.getIdentity()),
target == null ? null : labelFieldName(target), true);
+ putInheritedPersonalReadOnly(fkProperty, relation, composition);
properties.add(fkProperty);
relations.add(relationLink(name, relation, target, targetPerspective));
}
@@ -1917,6 +1918,24 @@ private static void putPersonal(Map p, RelationIntent relation,
(targetIdentityLabel == null || targetIdentityLabel.isBlank()) ? targetIdentityProperty : targetIdentityLabel);
}
+ /**
+ * Emit the see-only marker on the composition edge a child inherits its personal scope through
+ * ({@code personalReadOnly: true} without {@code personal: true} - dirigible #7340). The parent's
+ * own personal surface stays writable; the child's generated {@code MyController} refuses every
+ * write with 403 and its personal pages render no write affordance. Only the entity's OWNING
+ * composition carries it - a later composition is emitted as a plain association, which is exactly
+ * what the {@code composition} flag here says, and the parser refuses the key on any other edge.
+ *
+ * @param p the FK property being emitted
+ * @param relation the relation
+ * @param composition whether this relation is the entity's owning composition edge
+ */
+ private static void putInheritedPersonalReadOnly(Map p, RelationIntent relation, boolean composition) {
+ if (composition && relation.isPersonalReadOnly() && !relation.isPersonal()) {
+ p.put("relationshipPersonalReadOnly", "true");
+ }
+ }
+
/**
* Emit the partner-owner attributes for a relation that declares {@code partner: true} - the exact
* mirror of {@link #putPersonal} for the external Partner shell. The generated partner REST
diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java
index f3d8978db87..796031a8e93 100644
--- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java
+++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java
@@ -3745,6 +3745,7 @@ private static Set validateEntities(IntentModel model, Set usesA
+ "] is marked composition but only a manyToOne/oneToOne relation can be a composition");
}
validateWhenMasterDeleted(entity, relation, issues);
+ validateInheritedPersonalReadOnly(entity, relation, byName, issues);
boolean crossModel = relation.isCrossModel();
if (crossModel) {
// A cross-model relation references an entity owned by another intent model declared in
@@ -4652,6 +4653,50 @@ private static void validateWhenMasterDeleted(EntityIntent entity, RelationInten
}
}
+ /**
+ * {@code personalReadOnly: true} on a relation that does NOT declare {@code personal: true}: the
+ * composition edge a child inherits its personal scope through, opting that child's personal
+ * surface out of writes while the parent's own stays writable (dirigible #7340). The scope still
+ * comes from the parent; the writes do not - which is what a user-authored header whose lines only
+ * an engine writes needs (a leave request whose day rows a delegate charges against an
+ * entitlement). Anywhere else the key would be carried nowhere, so it is refused rather than
+ * silently dropped: it must sit on a composition, on the entity's FIRST one (every later
+ * composition is emitted as a plain association, so nothing would read it), and on a child that
+ * really does inherit a personal surface through that parent.
+ *
+ * @param entity the entity declaring the relation
+ * @param relation the relation
+ * @param byName the declared entities of this model, by name
+ * @param issues the issue list to add to
+ */
+ private static void validateInheritedPersonalReadOnly(EntityIntent entity, RelationIntent relation,
+ java.util.Map byName, List issues) {
+ if (!relation.isPersonalReadOnly() || relation.isPersonal()) {
+ return;
+ }
+ String subject = "entity [" + entity.getName() + "] relation [" + relation.getName() + "]";
+ if (!relation.isComposition()) {
+ issues.add(subject + " declares personalReadOnly but neither personal: true nor composition: true - declare it alongside"
+ + " personal: true to make this entity's own personal surface see-only, or on the composition relation the entity"
+ + " inherits its personal scope through to make the inherited one see-only");
+ return;
+ }
+ for (RelationIntent candidate : entity.getRelations()) {
+ if (candidate.isComposition()) {
+ if (candidate != relation) {
+ issues.add(subject + " declares personalReadOnly but the entity's owning composition is [" + candidate.getName()
+ + "] - only the first composition carries the inherited personal scope, so declare it there");
+ return;
+ }
+ break;
+ }
+ }
+ if (!hasPersonalSurface(byName, byName.get(relation.getTo()), new HashSet<>())) {
+ issues.add(subject + " declares personalReadOnly but its master [" + relation.getTo()
+ + "] has no personal surface to inherit - there is no personal surface here to make see-only");
+ }
+ }
+
/**
* {@code leafOnly: true} restricts a to-one relation to leaf nodes of its target's hierarchy, so
* the target must declare one. A same-model target is checked here; a cross-model target is
diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md
index 6ca8b722174..2c62674fb8a 100644
--- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md
+++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md
@@ -787,7 +787,14 @@ serves the scoped reads but its create/update/delete return **403**, and the my
write affordance at all - no New on the list, no Save/Delete on the form or the document, and no
Add on a child panel or on the document's items - for records the owner may view but never author
(a leave-balance account, a payslip); the regular (power) controller still writes them normally.
-The regular controller is unaffected. Sensitivity propagates to derived fields automatically: a rollup target (`op: sum` /
+The regular controller is unaffected. The same key on a CHILD's **composition** relation makes only
+that child's inherited surface see-only while the parent it inherits the scope from stays writable -
+the scope still comes from the parent, the writes do not - which is the shape of a header the person
+authors whose lines only an engine writes (a leave request whose day rows a delegate charges against
+an entitlement): the child's `MyController` 403s and the parent's my/document page renders no Add on
+that items panel, no row actions and no Add on that child panel. It is refused anywhere it would be
+carried nowhere - on a plain association, on a second composition, or on a child whose master has no
+personal surface to inherit. Sensitivity propagates to derived fields automatically: a rollup target (`op: sum` /
`latest`) whose `of:` child field is sensitive, and an `aggregate: true` master field fed by a
same-named sensitive item field, are treated as sensitive whenever their entity has a personal
surface (own `personal:` relation, or scope inherited through a composition parent chain) - the
diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java
index 8e92990c41d..46e1ce48ea8 100644
--- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java
+++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java
@@ -2184,6 +2184,48 @@ void identityPersonalAndSensitiveFlowIntoTheModel() {
assertNull(propertyByName(entityByName(entities, "VacationRequest"), "Note").get("sensitiveProperty"));
}
+ /**
+ * {@code personalReadOnly: true} on the composition edge a child inherits its personal scope
+ * through (dirigible #7340) marks that edge see-only - the child's generated MyController refuses
+ * every write and its personal pages offer none - while the master's own personal surface, whose
+ * header the person really does author, stays writable.
+ */
+ @Test
+ void aCompositionChildCanBeSeeOnlyWhileItsMasterStaysWritable() {
+ String yaml = """
+ name: hr
+ entities:
+ - name: Employee
+ identity: email
+ fields:
+ - { name: id, type: integer, primaryKey: true, generated: true }
+ - { name: name, type: string, required: true, length: 200 }
+ - { name: email, type: string, required: true, unique: true, length: 320 }
+ - name: VacationRequest
+ fields:
+ - { name: id, type: integer, primaryKey: true, generated: true }
+ - { name: note, type: string, length: 400 }
+ relations:
+ - { name: Employee, kind: manyToOne, to: Employee, required: true, personal: true }
+ - name: VacationRequestItem
+ fields:
+ - { name: id, type: integer, primaryKey: true, generated: true }
+ - { name: day, type: date }
+ relations:
+ - { name: Request, kind: manyToOne, to: VacationRequest, composition: true, required: true,
+ personalReadOnly: true }
+ """;
+ List