Skip to content

[DISCUSSION - DO NOT MERGE] Sealing Permissionable / Inode on the real sources — the cost, the blocker, and the one branch that almost works (#34154) - #36994

Closed
fabrizzio-dotCMS wants to merge 3 commits into
mainfrom
issue-34154-sealed-hierarchy-experiment
Closed

[DISCUSSION - DO NOT MERGE] Sealing Permissionable / Inode on the real sources — the cost, the blocker, and the one branch that almost works (#34154)#36994
fabrizzio-dotCMS wants to merge 3 commits into
mainfrom
issue-34154-sealed-hierarchy-experiment

Conversation

@fabrizzio-dotCMS

@fabrizzio-dotCMS fabrizzio-dotCMS commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #36982, which turned resolvePermissionType's
thirteen chained instanceof tests into a pattern switch. That switch still ends in a default — the
catch-all that stays quiet when a new asset type appears. Sealing the hierarchy is what would let the
default go, so this measures whether that is possible.

Two experiments, because they answer different halves:

What it does
real-tree/ declares the real Permissionable, Inode and ContentType sealed, in place, and runs the real Maven compile — real classpath, real Immutables processor, every dotCMS subclass in scope
src/ a 40-type model of the same hierarchy, outside every source root, for the parts the real tree cannot reach (it never compiles)
cd docs/experiments/sealed-permissionable/real-tree && ./run-on-real-tree.sh   # ~4 min
cd .. && ./run.sh                                                              # ~10s, JDK only

No production file is modified by this PR. run-on-real-tree.sh applies a patch to
dotCMS/src/main/java, records what came back, and reverts; it refuses to start if that tree is dirty.
Every number below is its output, committed under real-tree/results/.

Sealing Permissionable is a 22-file change before it is a module problem

One line changed — public interfacepublic sealed interface … permits <22 types>:

distinct errors: 42
   20  cannot extend a sealed class in a different package
   22  sealed, non-sealed or final modifiers expected

The first 20 are the known rule (unnamed module ⇒ permitted subtypes must share the package; only
Treeable and Ruleable already do). The other 22 are the part nobody predicts: every permitted
subtype must itself be re-declared final/sealed/non-sealed. Sealing Inode gives the same shape
at smaller scale — 5 + 6.

A control run with one subtype deliberately left out shows javac names what is missing, by name, even
with the rest of the clause already in error — so the zero not allowed to extend results elsewhere
mean the implementor lists are complete for dotCMS/src/main/java, not merely unreported.

ContentType: the one branch where packages are not the obstacle — and it still fails

All nine ContentType subclasses live in its own package, so the module rule has nothing to bite on.
Seal it anyway and exactly two errors come back, of a kind no permits clause can fix:

    2  anonymous classes must not extend sealed classes
  • com/dotcms/contenttype/transform/contenttype/StructureTransformer.java:102new ContentType() { … }
  • com/dotcms/contenttype/transform/contenttype/DbContentTypeTransformer.java:60 — same

Two refactors (anonymous → named type) and that branch becomes sealable in place, today. That is the
one actionable item in the whole investigation, and it is invisible from a model of the hierarchy.

permits does work with generated classes — at a price

Every concrete ContentType is produced by the Immutables processor, so permits must name classes that
do not exist until the processor has run. First attempt:

target/generated-sources/…/ImmutableSimpleContentType.java:[1980,15]
error: class is not allowed to extend sealed class: SimpleContentType (as it is not listed in its 'permits' clause)

Line 1980 is not ImmutableSimpleContentType — it is static final class Json extends SimpleContentType,
a nested helper the generator emits for Jackson. Name both and the real build goes green:

permits ImmutableSimpleContentType, ImmutableSimpleContentType.Json {
BUILD SUCCESS — exit: 0

So javac resolves processor-generated classes fine, but sealing an @Value.Immutable type writes the
generator's package-private internals into your own declaration — which move when its version does.

From the model: three results the real tree cannot reach

The real tree stops at the package rule, so it can never show a working sealed hierarchy.

The hierarchy is not the shape any of us remembers. Compiling Inode permits IHTMLPage, Container, Link, Contentlet verbatim gives ten errors, each a fact: Contentlet is no relation of Inode
(it implements Permissionable directly), Container/Link arrive via WebAsset, a page is a
Contentlet, and Host extends Contentlet. Writing the clause down is what disproves the picture.

Sealing Inode buys exhaustiveness nothing while Inode is instantiable. Add a subclass and it
compiles in silence: Inode is not abstract (Inode.java:40), so the compiler demands a case for the
base type, and case Inode _ swallows every future subclass — a default in all but name. Make it
abstract and the switch finally breaks. Also: the top-level switch covers the whole subtree with one
case Inode i, sealed or not, so the Inode-level seal only ever pays off one level down.

Eight of the resolver's 23 cases exist only to satisfy the compiler, one per non-sealed
sub-interface, none reachable in practice — exhaustiveness is recursive and a non-sealed interface is
covered by nothing.

The part that is not a cost you can choose to pay

com.dotmarketing.beans, com.dotmarketing.business, …portlets.contentlet.model,
…htmlpageasset.model and com.dotcms.contenttype.model.type are all published to plugin bundles in
osgi-extra.conf (lines 126, 237, 336, 152, 27). Implementing Permissionable from a plugin is
supported today. Sealed it is not — and it cannot be opted in, because permitted subtypes must live in
the same module:

java.lang.IncompatibleClassChangeError: Failed same module check: subclass com.acme.plugin.AcmeAsset
is in module 'dotcms.plugin' with loader 'app', and sealed class
com.dotmarketing.business.Permissionable is in module 'dotcms.permissions' with loader 'app'

Enforced at compile time and again at class load. Every existing plugin implementing Permissionable
fails on startup with no source change on their side.

Sealing Permissionable is not a refactor with a price tag. It is the removal of a published
extension point.

The honest limit

Even fully sealed, this resolver keeps most of its shape: half its branches do not dispatch on Java
types. A Site usually arrives as a plain Contentlet whose content type is named Host — a row in the
database. Sealed types verify the variants that live in the type system; dotCMS's live in the DB. That is
why the method's default is honesty rather than laziness.

Testing

real-tree/run-on-real-tree.sh (5 real Maven compiles, ~4 min, reverts the tree) and run.sh (the
model, ~10s). Expected: A exit 1 · B exit 1 · B2 exit 1 · C exit 1 · D exit 0; model
1:1 2:0 3:1 4:1 5a:0 5b:1 6:1 7a:1 7b:1. Baseline ./mvnw compile -pl :dotcms-core is green on this
branch, and git status is clean after a run.

Breaking Changes

None — no production file is modified.

Context

Devoxx Belgium 2025 Lunch and Learn groundwork. Supersedes the closed #36992, which only modelled the
first level and never touched the real sources.

This PR fixes: #34154

🤖 Generated with Claude Code

fabrizzio-dotCMS and others added 2 commits August 10, 2026 17:31
…34154)

NOT FOR MERGE. Groundwork for the Java 25 talk. Three compilations that
answer a question the codebase keeps raising: resolvePermissionType
dispatches over Permissionable and ends in a default, so the day someone
adds an asset type the code keeps compiling and the new type quietly
takes the default path. Sealing is what would remove that default, and
with it the silence.

The sources live under docs/ and outside every Maven source root on
purpose: two of the three experiments are supposed to fail to compile,
and the failures are the result. run.sh reproduces all three with
nothing but a JDK 22 — no Maven, no dotCMS classpath, no network.

Sealing it where it lives fails with one error per permitted subtype: a
sealed type in the unnamed module requires every permitted subtype in
the same package, and dotCMS has no module-info. Against the real type
that is fourteen errors, one per implementor, spread over nine packages.
Moving them into one package is not an import refactor either — the
canonical names are persisted data, hardcoded even in this class's SQL.

The same sources inside a named module compile, and the resolver drops
its default. Adding a fifteenth permitted type then breaks that resolver
without anyone touching it.

So the measurement: sealing this hierarchy is blocked by neither design
nor size. It costs a module-info.java, and in exchange the compiler
names every place that needs updating when an asset type is added.

The README closes on the limit, because it matters more than the win:
half this resolver's branches dispatch on a content type stored in the
database rather than on a Java type. Sealed verifies the variants that
live in the type system; dotCMS keeps its variants in a table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lly costs (#34154)

Round 1 sealed Permissionable over four representative subtypes and declared Inode
non-sealed. This models the complete hierarchy — 40 types, 25 packages — and seals it
two levels deep: Permissionable -> Inode -> WebAsset, with Contentlet sealing its own
eight subclasses.

Four results the first round could not produce:

- The permits clause everyone writes from memory (Inode permits IHTMLPage, Container,
  Link, Contentlet) fails with ten errors, each one a fact about the real hierarchy:
  Contentlet is no relation of Inode, Container and Link arrive via WebAsset, and a
  page is a Contentlet. Writing the clause is what disproves the mental model.
- Sealing Inode buys exhaustiveness NOTHING while Inode stays instantiable: the
  compiler requires a case for the base type itself, and that case is a default in all
  but name. Adding a subclass compiles in silence until Inode is made abstract.
- The eight sub-interfaces of Permissionable cost eight unreachable switch cases out of
  23 — exhaustiveness is recursive, and a non-sealed interface is covered by nothing.
- Sealing is not a refactor with a price tag but the removal of a published extension
  point: com.dotmarketing.beans and .business are exported to plugin bundles, and a
  plugin can never be a permitted subtype. Demonstrated at compile time and again at
  class load, with IncompatibleClassChangeError.

Still docs-only and still DO NOT MERGE: the sources sit outside every Maven source root
because five of the seven experiments are supposed to fail, and run.sh needs only a JDK.

Supersedes the closed #36992.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

❌ Linked Issue Needs Team Label

This PR is linked to issue #36992, but that issue has no Team : * label. Every linked issue must be owned by a team for tracking and triage.

How to fix this:

Apply a Team : * label to the linked issue (e.g., Team : Scout, Team : Platform, Team : Falcon, Team : Maintenance). Then push a new commit or edit the PR description to re-run this check.


This comment was automatically generated by the issue linking workflow

@github-actions github-actions Bot added the Area : Documentation PR changes documentation files label Aug 11, 2026
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fabrizzio-dotCMS's task in 2m 24s —— View job


Review

Docs-only experiment, DO NOT MERGE. I focused on whether the scripts actually produce the claimed results and whether the in-source narrative is self-consistent. The mechanics are sound: run.sh is internally consistent (the perl anchors in experiments 5b/6 line up with the real comment markers in the resolver sources, the removed-case arithmetic matches the "8 of 23" claim), and run-on-real-tree.sh is careful — it refuses to run on a dirty tree and reverts via an EXIT trap. The case counts and sealed shapes in the model match what the README asserts.

The findings are all cross-reference errors in the in-source javadoc — worth fixing precisely because this PR's whole thesis is precision, but non-blocking.

New Issues

  • 🟡 Medium: docs/experiments/sealed-permissionable/src/module-info.java:10 — "Experiment 8 is about what sealing does to that promise." There is no experiment 8. The osgi-extra.conf / plugin-extension-point scenario is Experiment 7 (README.md:249, run.sh stage 7a/7b). Fix this →

  • 🟡 Medium: docs/experiments/sealed-permissionable/src/com/dotmarketing/business/InodePermissionResolver.java:21-22 — "Add a seventh Inode subclass and this method … is what stops compiling. That is experiment 7." Adding a seventh Inode subclass (Rating) is Experiment 5 (README.md:218, run.sh stage 5a/5b); experiment 7 is the OSGi wall. Should read "experiment 5". Fix this →

  • 🟡 Medium: docs/experiments/sealed-permissionable/src/com/dotmarketing/business/InodePermissionResolver.java:17 — javadoc says "Nine cases, no default, and it is exhaustive" but the switch below has ten case arms (the ninth non-Inode case plus the extra case Inode _ at line 56 — the very case the comment block at 46-56 says exists only because Inode is concrete). The "nine cases" describes the abstract-Inode ideal, not the code as written; either reword to "ten cases (nine, once Inode is abstract)" or make the count match. Fix this →

  • 🟡 Medium: docs/experiments/sealed-permissionable/src/com/dotmarketing/beans/Inode.java:15-17 — "Contentlet … is no relation of Inode. See experiment 4." The claim that Contentlet is not an Inode is what Experiment 3 proves (the intuitive permits clause failing with "must extend sealed class" — README.md:160). Experiment 4 adds a brand-new top-level asset type; it's related but isn't the demonstration of this specific fact. Consider pointing at experiment 3. Assumption: the intent was to cite the experiment that disproves the "Contentlet is an Inode" model. What to verify: whether the author meant 3 (the disproof) or 4 (the analogy). Fix this →

The experiment design itself is solid — the scripts, patches, and recorded error outputs support the README's numbers.
· issue-34154-sealed-hierarchy-experiment

…es (#34154)

The model tree answered what a sealed hierarchy would look like; it could not answer
what dotCMS's own compiler says. This applies `sealed` to the real Permissionable,
Inode and ContentType, runs the real Maven compile against the real classpath and the
real Immutables processor, records the output, and reverts. No production file is
modified by this commit — real-tree/run-on-real-tree.sh patches, measures, and restores.

Four things only the real tree could produce:

- Sealing Permissionable is a 22-file change before it is a module problem. One line
  gives 42 distinct errors: 20 "cannot extend a sealed class in a different package"
  and 22 "sealed, non-sealed or final modifiers expected" — every permitted subtype
  must be re-declared. Inode: 5 + 6.
- ContentType is the one branch where packages are not the obstacle (all nine
  subclasses share its package) and it still fails, with two errors of a kind no
  permits clause can fix: `new ContentType() {}` in StructureTransformer:102 and
  DbContentTypeTransformer:60. Anonymous classes cannot extend a sealed class. Two
  refactors and that branch becomes sealable in place.
- permits DOES resolve annotation-processor-generated classes: SimpleContentType seals
  green over ImmutableSimpleContentType — but only once the generator's package-private
  nested `Json` subclass is named too, which couples the declaration to Immutables
  internals. This closes the open question the model left.
- A control run (one subtype deliberately omitted) proves javac names what is missing,
  so the zero "not allowed to extend" results elsewhere mean the implementor lists are
  complete for dotCMS/src/main/java rather than merely unreported.

Also worth knowing for anyone reading a raw log: annotation processing makes javac
report every diagnostic twice, so the honest counts are half the raw ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS fabrizzio-dotCMS changed the title [DISCUSSION - DO NOT MERGE] Sealing Permissionable AND Inode: what it costs, and what it breaks (#34154) [DISCUSSION - DO NOT MERGE] Sealing Permissionable / Inode on the real sources — the cost, the blocker, and the one branch that almost works (#34154) Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Documentation PR changes documentation files

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[TASK] Lunch and Learn — Devoxx Belgium 2025: Java 21→25 in the dotCMS codebase

1 participant