Skip to content

feat(intent): personal surfaces phase A — identity/personal/sensitive + the generated my-controller#6260

Merged
delchev merged 5 commits into
masterfrom
feat/personalized-surfaces
Jul 11, 2026
Merged

feat(intent): personal surfaces phase A — identity/personal/sensitive + the generated my-controller#6260
delchev merged 5 commits into
masterfrom
feat/personalized-surfaces

Conversation

@delchev

@delchev delchev commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

One model, two generated surfaces

For entities that are a person's own records (vacation requests, timesheet day allocations), one generated UI is wrong for everybody: the back-office user needs everything; the person needs a surface bound to themselves with confidential fields absent. Per-role field rules sprinkled through one UI rot and leak; this PR generates a second, scoped surface instead — safe by construction.

The DSL (three words)

- name: Employee
  identity: email        # matched against the logged-in username

- name: VacationRequest
  fields:
    - { name: dailyRate, type: decimal, sensitive: true }
  relations:
    - { name: Employee, kind: manyToOne, to: Employee, personal: true }

What gets generated (phase A: the REST contract)

An additional <Entity>MyController per personalized entity:

  • reads filtered to the current user's mapped identity record (Criteria.eq(identityProperty, User.getName())); no mapping row → empty, never an error;
  • the owner FK forced server-side on create/update — whatever the client sends is overwritten; re-parenting is not a personal-surface operation;
  • foreign and missing records are the same 404;
  • sensitive fields stripped from responses and ignored on writes (kept values on update) — enforcement is in the serialization, not the UI;
  • composition children inherit the scope through their direct parent (requireMyParent ancestor guard);
  • immutable/immutableWhen guards preserved; the power controller is untouched.

Parser validations: identity must be an own string field; one personal relation per entity; target must declare identity (same-model at parse, cross-model at generation, loud); sensitive never on the PK or the identity field.

Tests

  • IntentParserTest (5 new) + EdmIntentGeneratorTest (emission of identityProperty/relationshipPersonal/relationshipIdentityProperty/sensitiveProperty).
  • IntentEmissionCoverageIT — fixture gains the Person/Claim/ClaimLine slice (the IT user maps via an email: admin seed) and asserts both layers: the generated tokens, then the published app over REST — scoped list with the sensitive field null, 404 for the foreign row, spoofed owner/rate on create forced/ignored, update keeps the stored sensitive value, child lines guarded through the parent. Green in 46s.

Design & phasing

PERSONALIZATION_PLAN.md (repo root, HARMONIA_RUNTIME_PLAN.md-style) records the agreed decisions and the follow-up phases: B personal UI ("My …" pages), C My Shell (/services/web/my/ aggregating all personal perspectives), D per-user task assignment for personal processes, E collection-driven generation, F item-dialog readOnly.

🤖 Generated with Claude Code

* the target's property - {@code name} resolves to the target's own display label (its authored
* name field or its synthesized {@code Name}).
*/
private static List<Map<String, Object>> buildLabelParts(EntityIntent entity, Map<String, EntityIntent> byName) {
delchev and others added 4 commits July 11, 2026 11:22
… DSL + the scoped my-controller

One model, two generated REST surfaces. 'identity: <field>' on the entity
representing the person declares how the logged-in username maps to a record;
'personal: true' on a record-owning to-one relation (one per entity, target
must declare identity - parse-checked same-model, generation-checked
cross-model via TargetInfo.identityProperty) makes the entity get an
ADDITIONAL generated <Entity>MyController: reads filtered to the mapped
identity record, the owner FK forced server-side on writes, foreign and
missing records the same 404, and 'sensitive: true' fields stripped from
responses AND ignored on writes (server-side allow-list - UI hiding alone
would be cosmetic security). Composition children inherit the scope through
their direct parent as an ancestor-ownership guard. The power controller is
untouched.

Emission: entity identityProperty, FK relationshipPersonal +
relationshipIdentityProperty, field sensitiveProperty; parameterUtils derives
personalProperty/personalParent/sensitiveProperties; generateUtils gains the
personalModels collection; rest-java registers EntityMyController.java.template
on it.

Design + phasing (personal UI, My Shell, per-user task assignee,
collection-driven generation): PERSONALIZATION_PLAN.md. Unit: parser (5) +
EDM emission; IntentEmissionCoverageIT extends the fixture and asserts both
layers - generated tokens and the published app's behavior over REST (scoped
list, 404 on foreign rows, forced owner, kept-on-update sensitive values,
child ancestor guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d}|format tokens)

An entity 'label:' expression synthesizes a stored, read-only Name property
recomputed by the generated repository on every write path (save/update and
updateWithoutEvent - workflow writes stamp label inputs like the document
number). Tokens are own fields or ONE-hop to-one relation properties with an
optional date format; deeper paths are rejected - labels compose by
referencing the related entity's own generated Name ({ProjectTimesheet.Name}
-> '2026 July - My Project - Ivan Georgiev'). Dropdown label resolution
prefers Name (same-model labelFieldName + cross-model labelField), so every
lookup to a labeled entity renders meaningfully instead of raw ids. Parser
rejects a label next to an authored name field and tokens referencing
sensitive fields.

Unit: parser + EDM emission (Name synthesis, labelParts, dropdown value).
IntentEmissionCoverageIT: computeName token + live computed Name on the
personal create ('spoofed (Admin)') and after update ('edited (Admin)').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…honors readOnly

Phase D: a user task with 'assignee: personal' emits
flowable:assignee="${__personalUser}" instead of a candidate group; the
trigger listener resolves the record's owner through the personal relation +
identity mapping and carries the username IN the start payload - assignee
expressions evaluate at task creation, inside Process.start, so a variable
set afterwards arrives too late (caught live by the IT: the task stayed
unassigned). Parser requires the trigger entity to have a personal relation.

Phase F: the document item dialog renders read-only columns (calculated /
intent readOnly) as plain values instead of controls - the save path already
ignored them, so the editable steppers were fake editability with silently
lost input.

IntentEmissionCoverageIT: ClaimConfirm process fixture; asserts the BPMN
assignee token, the trigger's __personalUser seeding, and - live - that the
confirm task lands ASSIGNED in the owner's (admin's) inbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase B: every personal entity gets generated my pages talking to the scoped
MyController only - a list per personal root and a create/edit form per
personal entity. The owner FK and sensitive fields are never rendered (the
form model simply does not carry them); children inheriting the personal
scope render as embedded panels on the parent's form: an x-h-calendar for
detailCalendar children (empty-day click books with the parent FK + clicked
date preset, event-click edits) or a table. The SPA gains /my/<Entity> routes
and a 'My' sidebar section; each personal root contributes a perspective to
the new application-personal-perspectives extension point.

Phase C: resources-my - the My Shell at /services/web/my/, the personal
counterpart of the shared application shell: aggregates every module's
personal perspectives under one 'my' group, task-first (the Inbox IS the
home page), no power surfaces (projections/documents/reports). Wired into
components/pom + group-ui.

Platform gotcha found live: the perspectives extension service classifies a
group by the PRESENCE of items[] and SILENTLY DROPS any perspective whose
groupId matches no group - the group definer must carry items: [].

IntentEmissionCoverageIT extended: my page emission (scoped controller,
sensitive/owner absent from the form, child my form, SPA routes, extension
point registration) + runtime (the My Shell serves; the aggregation lists
the published personal perspective).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@delchev delchev force-pushed the feat/personalized-surfaces branch from ee22582 to c8a3e30 Compare July 11, 2026 08:26
…ource collection

Phase E: schedules[].generate gains children[] - one child row of the just-
generated parent per element of a source collection: forEach {entity, match}
(rows of a LOCAL entity whose field equals a source-row field, e.g. one
timesheet line per assigned employee) or forEach {days: workingDays} +
dayField (one row per working day - Mon-Fri - of the month, e.g. one 8h day
allocation per working day). parent: names the child's to-one back to the
generated record; children nest one more level (depth two, parser-capped).
Everything pre-renders in the glue (the expansions convention) - the Job
template stays shape-only; numeric literal defaults render as BigDecimal
(a bare int literal broke the registry-wide javac batch, caught live by the
emission IT's implicit compile verification).

IntentEmissionCoverageIT: the monthly-claims schedule fixture (Person ->
Claim + per-working-day ClaimLine) asserts the generated job saves the
parent, iterates working days, and writes children through the child's
repository - and the published registry batch compiling IS the deepest
assert (a broken job fails every downstream check).

PERSONALIZATION_PLAN.md: all six phases done.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@delchev

delchev commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

All six phases are now on this branch (one commit each), plus the agreed label: display-name feature:

Phase What landed
A identity/personal/sensitive DSL + the generated scoped <Entity>MyController (row filter, forced owner, ancestor guard for children, sensitive stripping)
label entity label: → stored read-only Name recomputed on every write path; dropdown resolution prefers it everywhere (fixes raw-id lookups; labels compose across hops via {Parent.Name})
D assignee: personalflowable:assignee resolved through the identity mapping, carried in the start payload (assignee expressions evaluate at task creation, inside Process.start)
F document item dialog renders readOnly columns as values, not fake-editable controls
B generated personal UI: a "My …" list per personal root + a form per personal entity (owner/sensitive never rendered; children as embedded panels — x-h-calendar with date-click booking for detailCalendar children), SPA routes + sidebar section, perspectives on the new application-personal-perspectives extension point
C My Shell (resources-my, /services/web/my/): the personal counterpart of the application shell — task-first (Inbox is the home page), aggregates every module's personal pages under one group
E schedules[].generate.children[] — collection-driven generation: one child per matching row of a local entity or per working day of the month, nested to two levels (the monthly timesheet pre-generation shape)

Everything is asserted in IntentEmissionCoverageIT at both layers per the emission contract — the full fixture publishes and passes in ~50s, including live checks that the personally-assigned task lands in the owner's Inbox and the My Shell aggregation lists the published personal perspective.

Two platform gotchas found live and encoded in the docs: the perspectives extension service silently drops a perspective whose groupId matches no group (and a group definer must carry items: []), and BPMN assignee expressions evaluate inside Process.start so per-user variables must ride the start payload.

Formatting validated across all touched modules; the release-profile javadoc gate passes on engine-intent (rebased over #6261).

🤖 Generated with Claude Code

@delchev delchev merged commit a088449 into master Jul 11, 2026
10 checks passed
@delchev delchev deleted the feat/personalized-surfaces branch July 11, 2026 08:48
delchev added a commit that referenced this pull request Jul 11, 2026
…idebar was floating over content)

The My Shell index.html (resources-my, from the personalization shell #6260) never
closed its <div x-h-sidebar-content>. With that one </div> missing, the whole tag
nesting shifted by one: the footer's closes were consumed by sidebar-content and
sidebar, so the sidebar's <div x-h-split-panel> was never closed and the MAIN panel
nested inside it. The x-h-split then had a single child, collapsing the two-column
layout - the sidebar rendered as a floating overlay at the left, partially hiding the
main content.

Add the missing </div> after the domain-apps </template> (matching the shared
application shell). The document is now div-balanced (48/48).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Jul 11, 2026
…6265)

* fix(my-shell): close the sidebar-content div so the split lays out (sidebar was floating over content)

The My Shell index.html (resources-my, from the personalization shell #6260) never
closed its <div x-h-sidebar-content>. With that one </div> missing, the whole tag
nesting shifted by one: the footer's closes were consumed by sidebar-content and
sidebar, so the sidebar's <div x-h-split-panel> was never closed and the MAIN panel
nested inside it. The x-h-split then had a single child, collapsing the two-column
layout - the sidebar rendered as a floating overlay at the left, partially hiding the
main content.

Add the missing </div> after the domain-apps </template> (matching the shared
application shell). The document is now div-balanced (48/48).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(my-shell): toolbar title 'My Applications' (not 'Applications')

The main-panel toolbar reused the application shell's 'Applications' title; in the
personal My Shell it should read 'My Applications'. New i18n key
application-core:shell.nav.myApplications with that fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants