diff --git a/.github/modernize/assessment/engines/api-service-contracts.md b/.github/modernize/assessment/engines/api-service-contracts.md
new file mode 100644
index 000000000..d96e7e519
--- /dev/null
+++ b/.github/modernize/assessment/engines/api-service-contracts.md
@@ -0,0 +1,121 @@
+# API & Service Communication Contracts
+
+PhotoAlbum-Java exposes **5 HTTP endpoints** across three Spring MVC controllers; all communication is synchronous REST over HTTP with no API gateway, message broker, or external service integrations.
+
+## Service Catalog
+
+| Service | Port | Category | Purpose |
+|---------|------|----------|---------|
+| photoalbum-java-app | 8080 | Business | Spring Boot web application serving the photo gallery UI and REST upload API |
+| oracle-db | 1521 | Infrastructure | Oracle Database Free (third-party container) providing persistent BLOB and metadata storage |
+
+## API Endpoints Inventory
+
+| Service | Method | Path | Request Type | Response Type |
+|---------|--------|------|-------------|--------------|
+| HomeController | GET | `/` | — | HTML view (`index.html`) with `List` model |
+| HomeController | POST | `/upload` | `multipart/form-data` — `files` param (`List`) | JSON `200 OK` — `{success, uploadedPhotos[], failedUploads[]}` or `400 Bad Request` |
+| DetailController | GET | `/detail/{id}` | Path param `id` (String UUID) | HTML view (`detail.html`) with `Photo` model + nav IDs; redirects to `/` on not-found |
+| DetailController | POST | `/detail/{id}/delete` | Path param `id` (String UUID) | Redirect `302` to `/` with flash attributes |
+| PhotoFileController | GET | `/photo/{id}` | Path param `id` (String UUID) | Binary image response (`image/jpeg`, `image/png`, etc.) with `Cache-Control: no-store` headers; `404` if not found |
+
+## Management & Observability Endpoints
+
+| Service | Endpoint | Custom Metrics |
+|---------|----------|---------------|
+| photoalbum-java-app | None — Spring Boot Actuator is not on the classpath | None declared |
+
+No `/actuator/health`, `/actuator/metrics`, or `/actuator/prometheus` endpoints are available. There are no `@Timed` or custom Micrometer metric registrations in the codebase.
+
+## DTOs & Contracts
+
+**Service-level domain classes used in the API:**
+
+- **`Photo`** (JPA Entity / response model): Returned directly from service layer to controllers and rendered in Thymeleaf templates or serialized to JSON in the upload response. Not immutable — uses standard mutable POJO with getters/setters. Full field details are in `data-architecture.md`.
+- **`UploadResult`** (response DTO): Carries the outcome of a single-file upload operation: `success` (boolean), `fileName` (String), `photoId` (String UUID on success), and `errorMessage` (String on failure). Mutable POJO; provides a `failure(...)` static factory method. Consumed by `HomeController` to build the JSON upload response.
+
+No OpenAPI/Swagger specification, protobuf schemas, or GraphQL schemas are present. Jackson (via `spring-boot-starter-json`) handles JSON serialization for the upload response endpoint using default settings — no custom serializers or `ObjectMapper` configuration is declared.
+
+## Communication Patterns
+
+**Synchronous only.** All client-to-application communication is HTTP/1.1 REST. The application makes no outbound HTTP calls to other services; all downstream communication is via JDBC to Oracle.
+
+**Resilience:** No circuit breaker, retry policy, bulkhead, or timeout configuration is declared (no Resilience4j, Spring Retry, or equivalent). Failed database operations propagate as unchecked `RuntimeException` to the controller, which returns a `500` response or redirects to the home page.
+
+**Service discovery:** Not applicable — single-service deployment. The Oracle JDBC URL is hardcoded in `application.properties` (default profile) and overridden via environment variable `SPRING_DATASOURCE_URL` in the Docker profile.
+
+**Startup dependency chain:** The `docker-compose.yml` configures `photoalbum-java-app` to wait for `oracle-db` to pass its health check (`condition: service_healthy`) before starting. No application-level readiness probe is registered. For full Docker configuration details see `configuration-inventory.md`.
+
+**Security posture:** No authentication, authorization, or TLS is configured. Spring Security is not on the classpath. All five endpoints — including photo deletion (`POST /detail/{id}/delete`) and file upload (`POST /upload`) — are publicly accessible with no authorization checks. There is no CSRF protection and no HTTPS configuration.
+
+## Service Technology Matrix
+
+| Service | Web Framework | Data Access | Discovery | Gateway | Actuator/Health | Cache | Metrics |
+|---------|--------------|-------------|-----------|---------|----------------|-------|---------|
+| photoalbum-java-app | Spring MVC (servlet) | Spring Data JPA + Hibernate 5.6 | None | None | None | None | None |
+| oracle-db | N/A (third-party) | N/A | N/A | N/A | Docker healthcheck only | N/A | N/A |
+
+## Service Communication Sequence
+
+```mermaid
+sequenceDiagram
+ participant Client as "Browser / HTTP Client"
+ participant HomeCtrl as "HomeController"
+ participant DetailCtrl as "DetailController"
+ participant FileCtrl as "PhotoFileController"
+ participant PhotoSvc as "PhotoServiceImpl"
+ participant Repo as "PhotoRepository"
+ participant DB as "Oracle Database"
+
+ Note over Client,DB: Gallery page load
+ Client->>HomeCtrl: GET /
+ HomeCtrl->>PhotoSvc: getAllPhotos()
+ PhotoSvc->>Repo: findAllOrderByUploadedAtDesc()
+ Repo->>DB: SELECT ... FROM PHOTOS ORDER BY UPLOADED_AT DESC
+ DB-->>Repo: List of Photo rows
+ Repo-->>PhotoSvc: List
+ PhotoSvc-->>HomeCtrl: List
+ HomeCtrl-->>Client: 200 HTML (index.html, photo grid)
+
+ Note over Client,DB: Photo upload
+ Client->>HomeCtrl: POST /upload (multipart files)
+ loop For each file
+ HomeCtrl->>PhotoSvc: uploadPhoto(MultipartFile)
+ PhotoSvc->>PhotoSvc: Validate MIME type and size
+ alt Validation fails
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, error)
+ else Validation passes
+ PhotoSvc->>Repo: save(Photo with BLOB data)
+ Repo->>DB: INSERT INTO PHOTOS (... photo_data BLOB ...)
+ DB-->>Repo: saved Photo
+ Repo-->>PhotoSvc: Photo
+ PhotoSvc-->>HomeCtrl: UploadResult(success=true, photoId)
+ end
+ end
+ HomeCtrl-->>Client: 200 JSON {success, uploadedPhotos[], failedUploads[]}
+
+ Note over Client,DB: Serve photo binary
+ Client->>FileCtrl: GET /photo/{id}
+ FileCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>Repo: findById(id)
+ Repo->>DB: SELECT ... FROM PHOTOS WHERE ID = ?
+ DB-->>Repo: Photo row with BLOB
+ Repo-->>PhotoSvc: Optional
+ alt Photo found
+ PhotoSvc-->>FileCtrl: Optional.of(Photo)
+ FileCtrl-->>Client: 200 image/jpeg (binary BLOB data, no-cache headers)
+ else Not found
+ PhotoSvc-->>FileCtrl: Optional.empty()
+ FileCtrl-->>Client: 404 Not Found
+ end
+
+ Note over Client,DB: Delete photo
+ Client->>DetailCtrl: POST /detail/{id}/delete
+ DetailCtrl->>PhotoSvc: deletePhoto(id)
+ PhotoSvc->>Repo: delete(Photo)
+ Repo->>DB: DELETE FROM PHOTOS WHERE ID = ?
+ DB-->>Repo: OK
+ Repo-->>PhotoSvc: void
+ PhotoSvc-->>DetailCtrl: true
+ DetailCtrl-->>Client: 302 Redirect to /
+```
diff --git a/.github/modernize/assessment/engines/architecture-diagram.md b/.github/modernize/assessment/engines/architecture-diagram.md
new file mode 100644
index 000000000..e108a793e
--- /dev/null
+++ b/.github/modernize/assessment/engines/architecture-diagram.md
@@ -0,0 +1,96 @@
+# Architecture Diagram
+
+PhotoAlbum-Java is a Spring Boot 2.7 web application that allows users to upload, browse, and manage photos, storing image data as BLOBs in an Oracle database.
+
+## Application Architecture
+
+```mermaid
+flowchart TD
+ subgraph Client["Client Layer"]
+ Browser["Web Browser"]
+ end
+ subgraph App["Application Layer - Spring Boot 2.7 / Java 8"]
+ Web["Spring MVC Controllers\n(HomeController, DetailController,\nPhotoFileController)"]
+ Template["Thymeleaf Templates\n(index, detail, layout)"]
+ Service["Business Services\n(PhotoServiceImpl)"]
+ Validation["Bean Validation\n(Spring Validator)"]
+ end
+ subgraph Data["Data Layer"]
+ JPA["Spring Data JPA\n(PhotoRepository)"]
+ DB[("Oracle Database\n(FREEPDB1 / BLOB storage)")]
+ end
+
+ Browser -->|"HTTP GET / POST"| Web
+ Web -->|"renders"| Template
+ Web -->|"delegates"| Service
+ Service -->|"validates"| Validation
+ Service -->|"CRUD + native queries"| JPA
+ JPA -->|"JDBC / ojdbc8"| DB
+```
+
+### Technology Stack Summary
+
+| Layer | Technology | Version | Purpose |
+|-------|-----------|---------|---------|
+| Presentation | Spring MVC + Thymeleaf | 2.7.18 / 3.x | Server-side rendering and REST endpoints |
+| Business Logic | Spring Boot Service | 2.7.18 | Photo upload, retrieval, deletion, navigation |
+| Data Access | Spring Data JPA / Hibernate | 2.7.18 | ORM and query abstraction |
+| Database | Oracle Database (FREEPDB1) | Oracle XE / Free | Persistent storage for photo metadata and BLOBs |
+| Runtime | Java | 8 | Application runtime |
+| Build | Apache Maven | 3.x | Dependency management and build |
+| Testing | JUnit 5 + H2 | Spring Boot 2.7.18 | Unit and integration tests |
+
+### Data Storage & External Services
+
+Photos and their metadata (filename, MIME type, dimensions, upload timestamp) are stored entirely within an Oracle Database instance. The binary image content is persisted as a `BLOB` (`byte[]` mapped via `@Lob`) in the `photos` table, eliminating the need for a file system or external object store. The application connects to Oracle via the `ojdbc8` JDBC driver using a fixed data source configured in `application.properties`. No external caches, message brokers, or third-party APIs are used; all data flows are internal between the Spring Boot process and the Oracle instance.
+
+### Key Architectural Decisions
+
+- **BLOB storage in Oracle**: Images are stored directly in the database as byte arrays rather than on disk or in cloud object storage, simplifying deployment but coupling the app tightly to Oracle.
+- **Native SQL queries for Oracle-specific features**: The `PhotoRepository` uses Oracle-specific functions (`ROWNUM`, `TO_CHAR`, `NVL`, analytical `RANK() OVER`) in native queries to implement pagination, filtering, and ranking.
+- **Constructor injection + `@Transactional` service**: `PhotoServiceImpl` receives all dependencies via constructor and is annotated `@Transactional`, following standard Spring best practices for testability and transaction management.
+
+## Component Relationships
+
+```mermaid
+flowchart LR
+ subgraph Presentation["Presentation Layer"]
+ HomeCtrl["HomeController"]
+ DetailCtrl["DetailController"]
+ PhotoFileCtrl["PhotoFileController"]
+ end
+ subgraph Business["Business Logic Layer"]
+ PhotoSvc["PhotoService (interface)"]
+ PhotoSvcImpl["PhotoServiceImpl"]
+ end
+ subgraph DataAccess["Data Access Layer"]
+ PhotoRepo["PhotoRepository\n(JpaRepository)"]
+ end
+ subgraph Model["Domain Model"]
+ PhotoEntity["Photo (JPA Entity)"]
+ UploadResult["UploadResult (DTO)"]
+ end
+
+ HomeCtrl -->|"delegates upload/list"| PhotoSvc
+ DetailCtrl -->|"delegates view/delete/nav"| PhotoSvc
+ PhotoFileCtrl -->|"delegates file serve"| PhotoSvc
+ PhotoSvc -.->|"implemented by"| PhotoSvcImpl
+ PhotoSvcImpl -->|"queries/saves"| PhotoRepo
+ PhotoRepo -->|"maps to/from"| PhotoEntity
+ PhotoSvcImpl -->|"produces"| UploadResult
+ HomeCtrl -->|"returns"| UploadResult
+```
+
+### Component Inventory
+
+| Component | Layer | Type | Responsibility |
+|-----------|-------|------|---------------|
+| HomeController | Presentation | Spring MVC Controller | Handles GET `/` (gallery list) and POST `/upload` (multi-file upload); returns HTML view or JSON |
+| DetailController | Presentation | Spring MVC Controller | Handles GET `/detail/{id}` (single photo view) and POST `/detail/{id}/delete` (photo deletion) |
+| PhotoFileController | Presentation | Spring MVC Controller | Handles GET `/photo/{id}` to stream BLOB photo data with appropriate `Content-Type` headers |
+| PhotoService | Business Logic | Service Interface | Defines contract for all photo operations (list, get, upload, delete, navigation) |
+| PhotoServiceImpl | Business Logic | Service Implementation | Validates files (MIME type, size), reads bytes, extracts dimensions via `ImageIO`, persists via repository |
+| PhotoRepository | Data Access | Spring Data JPA Repository | Extends `JpaRepository`; provides CRUD plus Oracle-specific native queries for ordering, pagination, and statistics |
+| Photo | Domain Model | JPA Entity | Maps to `photos` table; holds metadata and `@Lob` binary image data |
+| UploadResult | Domain Model | DTO | Carries upload outcome (success flag, photo ID or error message) between service and controller |
+| MathUtil | Utility | Utility Class | General-purpose math helper utilities |
diff --git a/.github/modernize/assessment/engines/business-workflows.md b/.github/modernize/assessment/engines/business-workflows.md
new file mode 100644
index 000000000..b794120aa
--- /dev/null
+++ b/.github/modernize/assessment/engines/business-workflows.md
@@ -0,0 +1,209 @@
+# Core Business Workflows
+
+PhotoAlbum-Java is a personal photo gallery application that lets users upload, browse, view, and delete images stored in a central database.
+
+## Domain Entities
+
+| Entity | Service / Bounded Context | Description | Key Relationships |
+|--------|--------------------------|-------------|------------------|
+| Photo | Photo Management (single bounded context) | Represents an uploaded image with its binary content and descriptive metadata (name, size, MIME type, dimensions, upload timestamp) | Self-referential temporal ordering: photos are navigated sequentially by `uploadedAt` timestamp (previous / next) |
+
+## Service-to-Domain Mapping
+
+| Service | Domain Context | Owned Entities | External Dependencies |
+|---------|---------------|---------------|----------------------|
+| photoalbum-java-app | Photo Management | `Photo` | Oracle Database (persistence of metadata + BLOB image data) |
+
+This is a single-service, single-context application. There are no inter-service dependencies, event buses, or remote API calls to external services.
+
+## Primary Workflows
+
+### Workflow 1: Photo Upload
+
+A user selects one or more image files in the browser gallery and submits them. The application validates each file, extracts image dimensions, stores the binary content in Oracle, and returns a structured JSON response indicating which uploads succeeded and which failed.
+
+**Steps:**
+1. User submits a multipart POST request with one or more image files.
+2. Controller iterates over each `MultipartFile` and calls `PhotoService.uploadPhoto()`.
+3. Service validates the file's MIME type against the configured allow-list (`image/jpeg`, `image/png`, `image/gif`, `image/webp`).
+4. Service validates the file size does not exceed the configured maximum (10 MB).
+5. Service validates that the file is non-empty (size > 0).
+6. Service reads all bytes from the multipart stream and attempts to extract pixel dimensions using `ImageIO.read()`.
+7. Service constructs a `Photo` entity with a UUID primary key, binary data, metadata, and current timestamp; persists it to Oracle.
+8. Service returns an `UploadResult(success=true, photoId=)` to the controller.
+9. Controller aggregates all individual results into a JSON response: `{success, uploadedPhotos[], failedUploads[]}`.
+
+**Business rules involved:** File type validation, file size limit, empty file rejection, image dimension extraction (best-effort; non-critical failure is tolerated).
+
+---
+
+### Workflow 2: Gallery Browse
+
+A user loads the home page to see all uploaded photos in reverse-chronological order.
+
+**Steps:**
+1. User sends a GET request to `/`.
+2. Controller calls `PhotoService.getAllPhotos()`.
+3. Service queries Oracle for all photos ordered by `uploadedAt DESC` (native SQL).
+4. Controller passes the photo list to the Thymeleaf `index.html` template.
+5. Template renders a thumbnail grid; each thumbnail references `/photo/{id}` for the image binary.
+
+---
+
+### Workflow 3: View Photo Detail with Navigation
+
+A user clicks a photo to view it full-size with previous/next navigation.
+
+**Steps:**
+1. User sends a GET request to `/detail/{id}`.
+2. Controller calls `PhotoService.getPhotoById(id)`; redirects to `/` if not found.
+3. Controller calls `PhotoService.getPreviousPhoto(photo)` — queries Oracle for the most recent photo uploaded before the current one.
+4. Controller calls `PhotoService.getNextPhoto(photo)` — queries Oracle for the oldest photo uploaded after the current one.
+5. Controller passes `photo`, `previousPhotoId`, and `nextPhotoId` to the `detail.html` template.
+6. Template renders the full-size image (referencing `/photo/{id}`) and navigation arrows.
+
+---
+
+### Workflow 4: Serve Photo Binary
+
+The browser fetches the actual image bytes for rendering thumbnails and full-size views.
+
+**Steps:**
+1. Browser sends a GET request to `/photo/{id}` (triggered by an ` ` tag).
+2. Controller calls `PhotoService.getPhotoById(id)`; returns `404` if not found.
+3. Controller reads the `photoData` byte array from the `Photo` entity.
+4. Controller returns the binary BLOB with the stored MIME type and `Cache-Control: no-cache, no-store` headers.
+
+---
+
+### Workflow 5: Delete Photo
+
+A user deletes a photo from the detail page.
+
+**Steps:**
+1. User submits a POST request to `/detail/{id}/delete`.
+2. Controller calls `PhotoService.deletePhoto(id)`.
+3. Service looks up the photo by ID; returns `false` (not found) if absent.
+4. Service calls `PhotoRepository.delete(photo)`, removing the row (and BLOB) from Oracle.
+5. Controller sets a flash attribute (`successMessage` or `errorMessage`) and redirects to `/`.
+
+## Cross-Service Data Flows
+
+PhotoAlbum-Java is a monolithic single-service application. All data originates from and returns to a single Oracle database schema. There are no inter-service REST calls, event-driven integrations, or data aggregation across multiple upstream services.
+
+The only data composition that occurs is within the detail-view workflow, where the service makes three sequential queries (fetch current photo, fetch previous photo, fetch next photo) and the controller assembles the navigation context before rendering the template. This is intra-service composition, not cross-service.
+
+No circuit-breaker fallback paths apply — if Oracle is unavailable, all workflows fail with a runtime exception; there is no degraded-mode behavior implemented.
+
+## Business Workflow Sequence
+
+```mermaid
+sequenceDiagram
+ participant User as "User (Browser)"
+ participant HomeCtrl as "HomeController"
+ participant DetailCtrl as "DetailController"
+ participant FileCtrl as "PhotoFileController"
+ participant PhotoSvc as "PhotoService"
+ participant DB as "Oracle Database"
+
+ Note over User,DB: Workflow 1 - Upload Photo
+ User->>HomeCtrl: POST /upload (image files)
+ loop For each uploaded file
+ HomeCtrl->>PhotoSvc: uploadPhoto(file)
+ PhotoSvc->>PhotoSvc: Validate MIME type
+ alt Invalid MIME type
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, "type not supported")
+ else MIME type OK
+ PhotoSvc->>PhotoSvc: Validate file size <= 10MB
+ alt File too large
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, "exceeds size limit")
+ else Size OK
+ PhotoSvc->>PhotoSvc: Read bytes, extract dimensions (ImageIO)
+ PhotoSvc->>DB: INSERT Photo (UUID, BLOB, metadata, timestamp)
+ DB-->>PhotoSvc: Saved Photo
+ PhotoSvc-->>HomeCtrl: UploadResult(success=true, photoId)
+ end
+ end
+ end
+ HomeCtrl-->>User: JSON {success, uploadedPhotos[], failedUploads[]}
+
+ Note over User,DB: Workflow 2 - Browse Gallery
+ User->>HomeCtrl: GET /
+ HomeCtrl->>PhotoSvc: getAllPhotos()
+ PhotoSvc->>DB: SELECT all photos ORDER BY uploaded_at DESC
+ DB-->>PhotoSvc: List
+ PhotoSvc-->>HomeCtrl: List
+ HomeCtrl-->>User: HTML gallery page
+
+ Note over User,DB: Workflow 3 - View Photo Detail
+ User->>DetailCtrl: GET /detail/{id}
+ DetailCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>DB: SELECT photo WHERE id = ?
+ DB-->>PhotoSvc: Photo
+ PhotoSvc-->>DetailCtrl: Optional
+ alt Photo not found
+ DetailCtrl-->>User: Redirect to /
+ else Photo found
+ DetailCtrl->>PhotoSvc: getPreviousPhoto(photo)
+ PhotoSvc->>DB: SELECT older photo (UPLOADED_AT < current)
+ DB-->>PhotoSvc: Optional previous Photo
+ DetailCtrl->>PhotoSvc: getNextPhoto(photo)
+ PhotoSvc->>DB: SELECT newer photo (UPLOADED_AT > current)
+ DB-->>PhotoSvc: Optional next Photo
+ DetailCtrl-->>User: HTML detail page with nav arrows
+ User->>FileCtrl: GET /photo/{id} (image src)
+ FileCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>DB: SELECT photo_data BLOB WHERE id = ?
+ DB-->>PhotoSvc: Photo with BLOB
+ PhotoSvc-->>FileCtrl: Photo
+ FileCtrl-->>User: Binary image (MIME type, no-cache headers)
+ end
+```
+
+## Business Rules & Decision Logic
+
+### Validation Rules
+
+| Rule | Applies To | Behavior on Violation |
+|------|-----------|----------------------|
+| Allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp` | Upload | Returns `UploadResult(success=false)` with "File type not supported" message; upload for that file is skipped |
+| Max file size: 10,485,760 bytes (10 MB) | Upload | Returns `UploadResult(success=false)` with "File size exceeds XMB limit" message |
+| Non-empty file (size > 0) | Upload | Returns `UploadResult(success=false)` with "File is empty" message |
+| Non-null, non-blank photo ID | Detail view, file serve, delete | Redirects to `/` (detail/delete) or returns `404` (file serve) |
+| Maximum files per upload: 10 | Upload (multipart config) | Enforced at HTTP layer by Spring multipart `max-request-size=50MB`; no explicit business-layer enforcement of the `max-files-per-upload=10` property |
+
+### Decision Logic
+
+- **Batch upload result aggregation**: A POST `/upload` request with multiple files is processed file-by-file. Individual failures do not abort the entire batch; `success` in the response is `true` if at least one file uploaded successfully.
+- **Image dimensions (best-effort)**: `ImageIO.read()` is attempted to extract pixel width/height. If it returns `null` or throws, the photo is still persisted without dimensions — dimension extraction failure is non-fatal.
+- **Navigation boundary**: `getPreviousPhoto` and `getNextPhoto` return `Optional.empty()` when no older/newer photo exists; the template hides the corresponding navigation arrow.
+
+### State Transitions
+
+`Photo` has a simple two-state lifecycle:
+
+```
+[Uploaded / Persisted] → (user deletes) → [Deleted / Removed]
+```
+
+There are no intermediate states (draft, pending, approved). Once saved, a photo is immediately visible in the gallery.
+
+### Transaction Boundaries
+
+- `PhotoServiceImpl` is annotated `@Transactional` at the class level — all public methods participate in a transaction by default.
+- Read operations (`getAllPhotos`, `getPhotoById`, `getPreviousPhoto`, `getNextPhoto`) are overridden with `@Transactional(readOnly = true)` to allow Hibernate optimizations.
+- Each upload call (`uploadPhoto`) runs in its own transaction — a failure for one file does not roll back uploads that already completed.
+
+### Error Handling
+
+- All service-layer exceptions are caught in the controller and either result in a redirect to `/` (with flash error message) or a JSON `failedUploads` entry. No custom business exception types are defined.
+- Unexpected exceptions in `getAllPhotos` cause the gallery to render with an empty list rather than a 500 error page.
+- `deletePhoto` throws `RuntimeException` on unexpected errors, which surfaces as a 500 if uncaught by the controller.
+
+### Authorization
+
+No authentication or authorization is implemented. All workflows are available to any anonymous HTTP client. See `api-service-contracts.md` for security posture details.
+
+### Audit / Logging
+
+Business operations are logged at DEBUG level (INFO in Docker) using SLF4J. Notable log events: successful upload with photo ID, upload rejection with reason, deletion confirmation. There is no formal audit trail, change-event log, or external audit sink.
diff --git a/.github/modernize/assessment/engines/configuration-inventory.md b/.github/modernize/assessment/engines/configuration-inventory.md
new file mode 100644
index 000000000..e1e23d2d2
--- /dev/null
+++ b/.github/modernize/assessment/engines/configuration-inventory.md
@@ -0,0 +1,157 @@
+# Configuration & Externalized Settings Inventory
+
+PhotoAlbum-Java uses three Spring Boot property files (default, docker, test profiles) as its sole configuration source, with secrets supplied via plain-text properties and Docker environment variable overrides — no external config server or secret store is employed.
+
+## Configuration Sources
+
+| Source | Type | Path / Location | Notes |
+|--------|------|----------------|-------|
+| `application.properties` | Spring Boot default profile | `src/main/resources/application.properties` | Active in local development; connects to Oracle at `oracle-db:1521/FREEPDB1` |
+| `application-docker.properties` | Spring Boot `docker` profile | `src/main/resources/application-docker.properties` | Activated via `SPRING_PROFILES_ACTIVE=docker` in Docker Compose; overrides JDBC URL to `oracle-db:1521:XE` |
+| `application-test.properties` | Spring Boot `test` profile | `src/test/resources/application-test.properties` | Active during `mvn test`; substitutes Oracle with H2 in-memory DB |
+| `docker-compose.yml` | Docker Compose environment | `docker-compose.yml` (root) | Injects `SPRING_PROFILES_ACTIVE`, `SPRING_DATASOURCE_URL/USERNAME/PASSWORD` into the app container; sets Oracle init-db environment variables |
+| `oracle-init/01-create-user.sql` | Oracle init script | `oracle-init/01-create-user.sql` | Executed automatically by the Oracle Free container on first start; creates `photoalbum` user with DBA privileges |
+| `oracle-init/02-verify-user.sql` | Oracle init script | `oracle-init/02-verify-user.sql` | Post-creation verification query |
+| `Dockerfile` | Container build config | `Dockerfile` (root) | Multi-stage build; sets default `JAVA_OPTS=-Xmx512m -Xms256m` |
+
+No Spring Cloud Config server, Kubernetes ConfigMaps, HashiCorp Vault, Azure Key Vault, or AWS Secrets Manager references are present. No `bootstrap.properties` or `bootstrap.yml` files exist.
+
+## Build Profiles
+
+| Profile | Activation | Purpose | Key Dependencies / Plugins |
+|---------|-----------|---------|---------------------------|
+| (default) | Automatic — no `-P` flag required | Standard local build with all dependencies; runs tests against H2 | `spring-boot-maven-plugin` for executable JAR; `spring-boot-starter-test` + H2 in test scope |
+| Docker multi-stage build | Triggered by `docker build` / `docker-compose up --build` | Compiles and packages in `maven:3.9.6-eclipse-temurin-8`; copies JAR to `eclipse-temurin:8-jre` runtime image | `mvn clean package -DskipTests` (skips tests inside Docker build); no additional Maven profiles declared in `pom.xml` |
+
+No explicit Maven `` blocks are declared in `pom.xml`. The only build variation is between a local Maven build (runs tests) and the Docker-contained build (skips tests).
+
+## Runtime Profiles
+
+| Profile | Activation Method | Config Files | Key Overrides vs Default |
+|---------|-----------------|-------------|-------------------------|
+| (default) | None — active when no profile is set | `application.properties` | Baseline — Oracle at `oracle-db:1521/FREEPDB1`, log level DEBUG for app code |
+| `docker` | `SPRING_PROFILES_ACTIVE=docker` (set in `docker-compose.yml`) | `application.properties` + `application-docker.properties` | JDBC URL changed to `oracle-db:1521:XE`; app log level reduced to INFO; Hibernate SQL log to DEBUG |
+| `test` | Applied automatically by `spring-boot-starter-test` via `application-test.properties` in test classpath | `application-test.properties` | Oracle replaced with `jdbc:h2:mem:testdb`; `ddl-auto=create-drop`; SQL logging disabled; upload path set to `target/test-uploads` |
+
+Multiple active profiles are not combined in any declared configuration. The `docker` profile fully composes with the base `application.properties` (Spring Boot merges them).
+
+## Properties Inventory
+
+### photoalbum-java-app — Server & Encoding
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `server.port` | `8080` | `8080` | — | `application.properties` |
+| `server.servlet.encoding.charset` | `UTF-8` | `UTF-8` | — | `application.properties` |
+| `server.servlet.encoding.enabled` | `true` | `true` | — | `application.properties` |
+| `server.servlet.encoding.force` | `true` | `true` | — | `application.properties` |
+
+### photoalbum-java-app — DataSource
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `spring.datasource.url` | `jdbc:oracle:thin:@oracle-db:1521/FREEPDB1` | `jdbc:oracle:thin:@oracle-db:1521:XE` | `jdbc:h2:mem:testdb` | Profile files |
+| `spring.datasource.username` | `photoalbum` | `photoalbum` | `sa` | Profile files |
+| `spring.datasource.password` | `photoalbum` [SENSITIVE] | `photoalbum` [SENSITIVE] | _(empty)_ | Profile files |
+| `spring.datasource.driver-class-name` | `oracle.jdbc.OracleDriver` | `oracle.jdbc.OracleDriver` | `org.h2.Driver` | Profile files |
+
+### photoalbum-java-app — JPA / Hibernate
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `spring.jpa.database-platform` | `org.hibernate.dialect.OracleDialect` | `org.hibernate.dialect.OracleDialect` | `org.hibernate.dialect.H2Dialect` | Profile files |
+| `spring.jpa.hibernate.ddl-auto` | `create` | `create` | `create-drop` | Profile files |
+| `spring.jpa.show-sql` | `true` | `true` | `false` | Profile files |
+| `spring.jpa.properties.hibernate.format_sql` | `true` | `true` | — | Profile files |
+
+### photoalbum-java-app — File Upload
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `spring.servlet.multipart.max-file-size` | `10MB` | `10MB` | — | `application.properties` |
+| `spring.servlet.multipart.max-request-size` | `50MB` | `50MB` | — | `application.properties` |
+| `app.file-upload.max-file-size-bytes` | `10485760` | `10485760` | `10485760` | Profile files |
+| `app.file-upload.allowed-mime-types` | `image/jpeg,image/png,image/gif,image/webp` | same | same | Profile files |
+| `app.file-upload.max-files-per-upload` | `10` | `10` | `10` | Profile files |
+| `app.file-upload.upload-path` | — | — | `target/test-uploads` | `application-test.properties` |
+
+### photoalbum-java-app — Logging
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `logging.level.com.photoalbum` | `DEBUG` | `INFO` | `DEBUG` | Profile files |
+| `logging.level.org.springframework.web` | `DEBUG` | `WARN` | — | Profile files |
+| `logging.level.org.hibernate.SQL` | — | `DEBUG` | — | `application-docker.properties` |
+
+## Startup Parameters & Resource Requirements
+
+| Service | JVM / Runtime Options | Memory | CPU | Instance Count |
+|---------|----------------------|--------|-----|---------------|
+| photoalbum-java-app (Docker) | `JAVA_OPTS=-Xmx512m -Xms256m` (set in Dockerfile `ENV`) | No `mem_limit` set in Compose | Not specified | 1 (no scaling config) |
+| photoalbum-java-app (local Maven) | JVM default (no explicit heap flags) | Host JVM defaults | Not specified | 1 |
+| oracle-db | Oracle Free container defaults | No `mem_limit` set in Compose | Not specified | 1 |
+
+The `JAVA_OPTS` environment variable is read by the `ENTRYPOINT` script (`sh -c "java $JAVA_OPTS -jar app.jar"`). It can be overridden at `docker run` time or via `docker-compose.yml` `environment` section. No `-Dspring.profiles.active` JVM system property is used; profile activation is exclusively via `SPRING_PROFILES_ACTIVE` environment variable.
+
+## Startup Dependency Chain
+
+```
+oracle-db → (Docker healthcheck: healthcheck.sh, interval 30s, timeout 10s, retries 15, start_period 180s)
+ ↓
+photoalbum-java-app → depends_on: oracle-db (condition: service_healthy)
+ restart policy: on-failure
+```
+
+1. **`oracle-db`** starts first. The Docker Compose health check runs `healthcheck.sh` inside the Oracle container every 30 seconds with a 180-second start period and up to 15 retries (~7.5 minutes total patience).
+2. **`photoalbum-java-app`** only starts after `oracle-db` reports healthy. No application-level readiness probe (Spring Boot Actuator is not on the classpath). If Oracle is unavailable after the container starts, the Spring application context will fail to initialize (Hibernate `ddl-auto=create` requires a live connection at startup) and Docker Compose will restart the container per `restart: on-failure`.
+
+There is no config-server, discovery-server, or API gateway in the startup chain.
+
+## Secrets & Sensitive Configuration
+
+| Secret Reference | Type | Profile | Storage |
+|----------------|------|---------|---------|
+| `spring.datasource.password` | Oracle DB password | default, docker | Plain-text in `application.properties` / `application-docker.properties` — value: [MASKED] |
+| `SPRING_DATASOURCE_PASSWORD` | Oracle DB password (env var override) | docker (Compose) | Plain-text in `docker-compose.yml` — value: [MASKED] |
+| `ORACLE_PASSWORD` | Oracle root/sys password | docker (Compose, oracle-db service) | Plain-text in `docker-compose.yml` — value: [MASKED] |
+| `APP_USER_PASSWORD` | Oracle app-user password | docker (Compose, oracle-db service) | Plain-text in `docker-compose.yml` — value: [MASKED] |
+
+### Secrets Provisioning Workflow
+
+All secrets are stored as plain-text values in source-controlled configuration files (`application.properties`, `docker-compose.yml`). There is no secrets management system in use:
+
+- **No external secret store**: No HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, or Kubernetes Secrets are referenced.
+- **No encryption**: No Jasypt, DPAPI, or sealed-secret encryption of property values.
+- **Credential flow**: Oracle credentials are hard-coded in `application.properties` (default profile) and additionally injected as `SPRING_DATASOURCE_*` environment variables in `docker-compose.yml`. The values in both locations are identical plain-text strings.
+- **Risk**: Committing database passwords to source control is a critical security issue. Any developer or CI system with repository read access can obtain the database credentials.
+
+**Recommended remediation**: Externalize secrets to a vault (e.g., Azure Key Vault with managed identity, or GitHub Actions secrets injected at deploy time) and remove credential values from all checked-in files.
+
+## Feature Flags
+
+No feature flag framework is present. No `@ConditionalOnProperty`, `@ConditionalOnExpression`, LaunchDarkly, Unleash, or custom toggle mechanism is used. The only conditional behaviour is the standard Spring Boot profile activation that selects the appropriate `application-{profile}.properties` file.
+
+| Flag Name | Default | Controlled By |
+|-----------|---------|--------------|
+| None detected | — | — |
+
+## Framework & Runtime Versions
+
+| Component | Version | Source |
+|-----------|---------|--------|
+| Java (compile target) | 8 (1.8) | `pom.xml` — `maven.compiler.source/target` |
+| Java (Docker build) | 8 (`eclipse-temurin:8`) | `Dockerfile` — `FROM maven:3.9.6-eclipse-temurin-8` / `FROM eclipse-temurin:8-jre` |
+| Spring Boot | 2.7.18 | `pom.xml` — parent BOM |
+| Spring MVC | 5.3.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-web` |
+| Hibernate | 5.6.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-data-jpa` |
+| Thymeleaf | 3.0.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-thymeleaf` |
+| Hibernate Validator | 6.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-validation` |
+| Jackson | 2.14.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-json` |
+| Oracle JDBC (ojdbc8) | Managed by Spring Boot BOM (Oracle 21c driver) | `pom.xml` — `com.oracle.database.jdbc:ojdbc8` |
+| Commons IO | 2.11.0 | `pom.xml` — explicit version |
+| H2 Database | 2.x (managed by Spring Boot 2.7.18 BOM) | `pom.xml` — test scope |
+| Maven | 3.9.6 (Docker build stage) | `Dockerfile` — `FROM maven:3.9.6-eclipse-temurin-8` |
+| Maven (local) | ≥ 3.x (not pinned) | System-installed; used for `mvn test` |
+| Docker base image (build) | `maven:3.9.6-eclipse-temurin-8` | `Dockerfile` |
+| Docker base image (runtime) | `eclipse-temurin:8-jre` | `Dockerfile` |
+| Oracle Database | Free 23ai (`gvenzl/oracle-free:latest`) | `docker-compose.yml` |
diff --git a/.github/modernize/assessment/engines/data-architecture.md b/.github/modernize/assessment/engines/data-architecture.md
new file mode 100644
index 000000000..9665cbb9e
--- /dev/null
+++ b/.github/modernize/assessment/engines/data-architecture.md
@@ -0,0 +1,79 @@
+# Data Architecture & Persistence Layer
+
+PhotoAlbum-Java has a single JPA entity (`Photo`) persisted in Oracle Database using Hibernate 5.6 via Spring Data JPA, storing image binary content as a BLOB column alongside metadata fields.
+
+## Database Configuration
+
+| Service/Module | DB Type | Profile | Driver | Connection | Migration Tool |
+|---------------|---------|---------|--------|-----------|---------------|
+| photoalbum-java-app | Oracle Database Free (FREEPDB1) | default (local) | ojdbc8 (Oracle JDBC) | `jdbc:oracle:thin:@oracle-db:1521/FREEPDB1` | None — Hibernate `ddl-auto=create` recreates schema on startup |
+| photoalbum-java-app | Oracle Database Free (XE) | docker | ojdbc8 (Oracle JDBC) | `jdbc:oracle:thin:@oracle-db:1521:XE` | None — Hibernate `ddl-auto=create` recreates schema on startup |
+| photoalbum-java-app | H2 (in-memory) | test | H2 JDBC Driver | `jdbc:h2:mem:testdb` | None — Hibernate `ddl-auto=create-drop` manages test schema |
+
+Schema management is handled entirely by Hibernate DDL auto-generation (`create` mode in runtime profiles, `create-drop` for tests). No Flyway or Liquibase migration tool is present; the `photos` table is dropped and recreated on every application startup, making this configuration unsuitable for production use without persistent data. The Oracle user (`photoalbum`) is provisioned by the init script `oracle-init/01-create-user.sql`, which is executed automatically when the Oracle container starts. No seed data files (`data.sql`, `import.sql`) are present. For full property key-value details see `configuration-inventory.md`.
+
+## Data Ownership per Service
+
+| Service | Tables Owned | ORM Framework | Caching | Notes |
+|---------|-------------|--------------|---------|-------|
+| photoalbum-java-app | `photos` | Hibernate 5.6 via Spring Data JPA | None | Single table stores all photo metadata and BLOB data; schema auto-generated by Hibernate on startup |
+
+## Entity Model
+
+```mermaid
+erDiagram
+ PHOTOS {
+ string id PK "UUID (length 36)"
+ string original_file_name "NOT NULL, max 255"
+ blob photo_data "nullable BLOB - binary image content"
+ string stored_file_name "NOT NULL, max 255 - GUID-based filename"
+ string file_path "nullable, max 500 - compatibility only"
+ number file_size "NOT NULL, NUMBER(19,0)"
+ string mime_type "NOT NULL, max 50"
+ timestamp uploaded_at "NOT NULL, DEFAULT SYSTIMESTAMP"
+ number width "nullable - image width in pixels"
+ number height "nullable - image height in pixels"
+ }
+```
+
+**Entity notes:**
+- `Photo` is annotated `@Entity @Table(name = "photos")` with a composite index on `uploaded_at` (`idx_photos_uploaded_at`).
+- The primary key `id` is a UUID string assigned in the default constructor (`UUID.randomUUID().toString()`); no `@GeneratedValue` strategy is used.
+- `photoData` is mapped with `@Lob` as a `byte[]`, storing the full binary image directly in Oracle.
+- `@Transactional` is applied at the `PhotoServiceImpl` class level; read-only methods override with `@Transactional(readOnly = true)`.
+
+## Key Repository Methods
+
+| Repository | Return Type | Method Signature | Purpose |
+|-----------|-------------|-----------------|---------|
+| PhotoRepository | `List` | `findAllOrderByUploadedAtDesc()` | Lists all photos newest-first for the gallery home page (native Oracle SQL) |
+| PhotoRepository | `Optional` | `findById(String id)` (inherited) | Fetches a single photo by UUID for detail view and file serving |
+| PhotoRepository | `List` | `findPhotosUploadedBefore(LocalDateTime uploadedAt)` | Returns up to 10 photos older than the given timestamp for backward navigation (uses Oracle `ROWNUM`) |
+| PhotoRepository | `List` | `findPhotosUploadedAfter(LocalDateTime uploadedAt)` | Returns photos newer than the given timestamp for forward navigation |
+| PhotoRepository | `List` | `findPhotosByUploadMonth(String year, String month)` | Filters photos by year/month using Oracle `TO_CHAR` — declared but not currently called from any controller |
+| PhotoRepository | `List` | `findPhotosWithPagination(int startRow, int endRow)` | Oracle `ROWNUM`-based pagination — declared but not currently called from any controller |
+| PhotoRepository | `List` | `findPhotosWithStatistics()` | Returns photos with `RANK() OVER` and running `SUM` analytical functions — declared but not currently called |
+| PhotoRepository | `void` | `delete(Photo)` (inherited) | Removes a photo record (and its BLOB) from the database |
+| PhotoRepository | `Photo` | `save(Photo)` (inherited) | Inserts or updates a photo record |
+
+All custom methods use `@Query(nativeQuery = true)` with Oracle-specific SQL. Source file: `src/main/java/com/photoalbum/repository/PhotoRepository.java`.
+
+## Caching Strategy
+
+No caching layer is configured. There are no `@Cacheable`, `@CacheEvict`, or `@CachePut` annotations, no cache provider (EhCache, Redis, Caffeine) on the classpath, and no Hibernate second-level cache configuration. Every HTTP request to the gallery home page (`GET /`) triggers a full `SELECT ... FROM PHOTOS ORDER BY UPLOADED_AT DESC` query against Oracle, and every `GET /photo/{id}` retrieves the full BLOB from the database. The `PhotoFileController` sets aggressive `Cache-Control: no-cache, no-store` response headers, which also prevents browser-side caching of image binaries.
+
+## Data Ownership Boundaries
+
+**Single-service, single-database topology.** There is only one deployable application service and one database. All data is owned by `photoalbum-java-app` in a single Oracle schema (`photoalbum` user). There are no cross-service data access patterns, shared databases, or inter-service queries.
+
+**Read/write pattern:** All reads and writes go through `PhotoRepository` (Spring Data JPA backed by Hibernate). There is no CQRS separation, read replica, or event sourcing; the same Oracle instance handles both queries and mutations.
+
+**Schema management risk:** Hibernate `ddl-auto=create` drops and recreates the `photos` table (including all BLOB data) on every application restart in both the default and Docker profiles. This is a critical data-loss risk in any environment where photos are expected to persist across restarts.
+
+### Data Classification & Sensitivity
+
+| Entity | Sensitive Fields | Classification | Controls in Place |
+|--------|----------------|---------------|-------------------|
+| Photo | `original_file_name` (user-provided filename), `photo_data` (image BLOB — may contain EXIF metadata including GPS coordinates, device identifiers) | Potentially PII (EXIF data embedded in images) | None — no encryption-at-rest, no EXIF stripping, no field-level access control, no masking |
+
+The `photos` table does not store explicit PII such as usernames, email addresses, or personal identifiers. However, uploaded image files may contain EXIF metadata (GPS location, device serial number, timestamps) embedded in the binary data, which constitutes PII under GDPR. The application reads image bytes directly via `ImageIO` to extract pixel dimensions but does not strip EXIF data before persisting the BLOB. No encryption-at-rest is configured for the Oracle tablespace, and there is no authentication layer protecting access to stored images.
diff --git a/.github/modernize/assessment/engines/dependency-map.md b/.github/modernize/assessment/engines/dependency-map.md
new file mode 100644
index 000000000..805515c9c
--- /dev/null
+++ b/.github/modernize/assessment/engines/dependency-map.md
@@ -0,0 +1,74 @@
+# Dependency Map
+
+PhotoAlbum-Java declares **10 dependencies** (8 production + 2 test-scoped) managed via Maven with the Spring Boot 2.7.18 parent BOM.
+
+## Dependencies
+
+```mermaid
+flowchart LR
+ App["PhotoAlbum\nSpring Boot 2.7.18"]
+
+ subgraph BOM["Parent BOM"]
+ ParentBOM["spring-boot-starter-parent\nv2.7.18"]
+ end
+ subgraph Web["Web Frameworks"]
+ SpringWeb["spring-boot-starter-web\n(Spring MVC + Tomcat)"]
+ Thymeleaf["spring-boot-starter-thymeleaf\n(Thymeleaf 3.x)"]
+ end
+ subgraph DB["Database / ORM"]
+ JPA["spring-boot-starter-data-jpa\n(Hibernate 5.6.x)"]
+ OracleJDBC["ojdbc8\n(Oracle JDBC - runtime)"]
+ end
+ subgraph Val["Validation"]
+ Validation["spring-boot-starter-validation\n(Hibernate Validator 6.x)"]
+ end
+ subgraph Util["Utilities"]
+ CommonsIO["commons-io\nv2.11.0"]
+ Jackson["spring-boot-starter-json\n(Jackson 2.14.x)"]
+ DevTools["spring-boot-devtools\n(optional - dev only)"]
+ end
+
+ ParentBOM -.->|"manages versions"| SpringWeb
+ ParentBOM -.->|"manages versions"| Thymeleaf
+ ParentBOM -.->|"manages versions"| JPA
+ ParentBOM -.->|"manages versions"| OracleJDBC
+ ParentBOM -.->|"manages versions"| Validation
+ ParentBOM -.->|"manages versions"| Jackson
+
+ App -->|"BOM"| BOM
+ App -->|"web"| Web
+ App -->|"persistence"| DB
+ App -->|"validation"| Val
+ App -->|"utilities"| Util
+```
+
+### Dependency Summary
+
+| Category | Count | Key Libraries | Notes |
+|----------|-------|--------------|-------|
+| Web Frameworks | 2 | Spring MVC (via spring-boot-starter-web), Thymeleaf 3.x | Legacy Spring Boot 2.x stack; Spring Boot 2.7.x reaches end-of-life Aug 2023 |
+| Database / ORM | 2 | Hibernate 5.6.x (via JPA starter), ojdbc8 (Oracle JDBC) | Oracle-specific dialect and native queries throughout; tightly coupled to Oracle |
+| Validation | 1 | Hibernate Validator 6.x (via validation starter) | Jakarta EE 8 / `javax.validation` namespace — must migrate to `jakarta.validation` for Spring Boot 3 |
+| Utilities | 3 | commons-io 2.11.0, Jackson 2.14.x, spring-boot-devtools | commons-io 2.11.0 is stable; devtools is optional/dev-only |
+
+### Version & Compatibility Risks
+
+Spring Boot 2.7.18 is the final 2.x release and reached **commercial end-of-life in August 2023** (OSS support ended February 2023). Migrating to Spring Boot 3.x requires upgrading the Java baseline to **Java 17** (from the current Java 8) and switching all `javax.*` imports to the `jakarta.*` namespace (EE 9+). Hibernate 5.6.x is also end-of-life; Spring Boot 3 bundles Hibernate 6, which has breaking API changes. The Oracle JDBC driver (`ojdbc8`) must be kept in sync with the Oracle server version; however the dependency version is managed by the Spring Boot BOM rather than declared explicitly, which may lag behind the latest certified driver. Commons IO 2.11.0 has no known CVEs but a newer 2.15.x line is available.
+
+### Notable Observations
+
+- **Java 8 baseline**: The application targets Java 8 (`maven.compiler.source=8`), which is significantly behind the current Java LTS (Java 21). Any migration to Spring Boot 3 mandates a minimum of Java 17; this represents a substantial upgrade effort.
+- **Oracle vendor lock-in**: The use of `ojdbc8`, Oracle-specific SQL (`ROWNUM`, `TO_CHAR`, `NVL`, `RANK() OVER`), and `OracleDialect` makes the data layer non-portable. Migrating to a managed cloud database (e.g., Azure Database for PostgreSQL) would require rewriting all native queries.
+- **No caching or messaging libraries**: The application has no declared caching (e.g., Redis/EhCache) or messaging (e.g., Kafka/Service Bus) dependencies. All photo data is fetched directly from Oracle on every request, which may become a performance bottleneck at scale.
+- **No security framework**: There is no Spring Security or equivalent dependency declared. The application has no authentication or authorization layer, meaning all endpoints are publicly accessible.
+
+## Test Dependencies
+
+| Framework | Version | Notes |
+|-----------|---------|-------|
+| spring-boot-starter-test | 2.7.18 (managed) | Bundles JUnit 5 (Jupiter), Mockito, AssertJ, Spring Test |
+| H2 Database | 2.x (managed by BOM) | In-memory database used as Oracle substitute in tests |
+
+Total test-scope dependencies: **2**
+
+The test setup relies on H2 as a stand-in for Oracle, which may hide Oracle-specific query incompatibilities (native SQL using `ROWNUM`, `TO_CHAR`, Oracle analytical functions) during unit testing. No integration testing framework (e.g., Testcontainers with an Oracle image) is present, meaning Oracle-specific code paths are not exercised in CI.
diff --git a/.github/modernize/assessment/engines/generate-report-html.py b/.github/modernize/assessment/engines/generate-report-html.py
new file mode 100644
index 000000000..122ee5070
--- /dev/null
+++ b/.github/modernize/assessment/engines/generate-report-html.py
@@ -0,0 +1,1410 @@
+#!/usr/bin/env python3
+"""
+Generates report.html from report.json (Java/.NET) or js-assessment-report.md (JS/TS).
+
+Usage:
+ python generate_report_html.py /path/to/.github/modernize/assessment/reports/report-{id}
+"""
+
+import json
+import math
+import os
+import re
+import sys
+from html import escape
+from pathlib import Path
+
+# ── CSS (exact copy from reference) ──────────────────────────────────────────
+CSS = r""":root {
+ --bg-primary: #ffffff;
+ --bg-card: #f8f9fa;
+ --bg-page: #f0f1f3;
+ --text-primary: #24292f;
+ --text-secondary: #57606a;
+ --text-muted: #6b7280;
+ --border-color: #d0d7de;
+ --border-light: #e1e4e8;
+ --link-color: #2563eb;
+ --color-mandatory: #E3008C;
+ --color-potential: #637CEF;
+ --color-optional: #A19F9D;
+ --font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Arial, sans-serif;
+ --font-mono: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
+}
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body { font-family: var(--font-family); font-size: 13px; color: var(--text-primary); background: var(--bg-primary); line-height: 1.5; margin: 0; padding: 0; }
+a { color: var(--link-color); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Page layout ───────────────────────────────────────────── */
+.main { margin: 0; padding: 24px 32px; background: var(--bg-primary); }
+.back-link { color: var(--text-muted); font-size: 13px; margin-bottom: 16px; display: block; }
+h1 { font-weight: 600; font-size: 20px; color: var(--text-primary); margin-bottom: 20px; }
+
+/* ── Report cards (matches VS Code .vscode-report-card) ──── */
+.report-card { background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 4px; padding: 12px; margin-bottom: 20px; }
+.report-card h2 { font-size: 14px; font-weight: 600; margin: 0; color: var(--text-primary); }
+.report-card-body { margin-top: 16px; }
+
+/* ── Application Information (two-column) ────────────────── */
+.app-info-container { display: flex; gap: 8px; flex-wrap: wrap; }
+.app-info-column { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 8px; }
+.app-info-row { display: flex; align-items: baseline; }
+.app-info-label { width: 136px; flex-shrink: 0; font-weight: 600; color: var(--text-primary); }
+.app-info-value { flex: 1; color: var(--text-primary); }
+
+/* ── Issue Summary (pie charts + legend) ─────────────────── */
+.cards-container { display: flex; align-items: stretch; gap: 20px; flex-wrap: wrap; }
+.issue-summary-card { flex: 2; }
+.domain-summary-container { display: flex; flex-wrap: wrap; gap: 20px; justify-content: space-around; }
+.domain-summary-item { flex: 1; min-width: 120px; text-align: center; padding: 0 16px; border-right: 1px solid var(--border-light); }
+.domain-summary-item:last-child { border-right: none; }
+.domain-summary-item h3 { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-top: 8px; }
+.domain-summary-item .count { font-size: 12px; color: var(--text-secondary); }
+.criticality-legend { display: flex; flex-wrap: wrap; gap: 15px; padding-top: 12px; justify-content: center; }
+.criticality-legend .legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-secondary); }
+.legend-swatch { display: inline-block; width: 1em; height: 1em; border-radius: 2px; }
+
+/* ── Issue list tables ───────────────────────────────────── */
+.issue-section { margin-top: 20px; }
+.issue-section h2 { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
+.issue-table { width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 600px; }
+.issue-table th { text-align: left; text-transform: uppercase; font-weight: 600; font-size: calc(13px * 0.875); padding: 6px 8px; background: var(--bg-card); border-bottom: 1px solid var(--border-color); color: var(--text-secondary); position: sticky; top: 0; z-index: 1; }
+.issue-table td { padding: 12px 8px; border-bottom: 1px solid var(--border-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
+.col-issue { width: 55%; }
+.col-criticality { width: 25%; }
+.col-storypoint { width: 20%; }
+
+/* ── Target dropdown ─────────────────────────────────────── */
+.target-select-container { margin: 4px 0 16px 0; display: flex; align-items: center; gap: 8px; }
+.target-select-container label { font-weight: 600; color: var(--text-primary); font-size: 14px; }
+.target-select-container select { font-family: var(--font-family); font-size: 13px; padding: 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); cursor: pointer; }
+
+/* ── Filter bar ──────────────────────────────────────────── */
+.filter-bar { display: flex; flex-wrap: wrap; gap: 16px; margin: 16px 0 8px 0; align-items: center; }
+.multi-select { position: relative; display: inline-block; min-width: 140px; }
+.multi-select-btn { font-family: var(--font-family); font-size: 13px; padding: 4px 24px 4px 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); cursor: pointer; width: 100%; text-align: left; position: relative; white-space: nowrap; }
+.multi-select-btn::after { content: '\25BE'; position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 11px; color: var(--text-secondary); }
+.multi-select-dropdown { display: none; position: absolute; top: 100%; left: 0; min-width: 100%; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 100; margin-top: 2px; }
+.multi-select.open .multi-select-dropdown { display: block; }
+.multi-select-option { display: flex; align-items: center; gap: 6px; padding: 5px 10px; cursor: pointer; font-size: 13px; white-space: nowrap; }
+.multi-select-option:hover { background: var(--bg-secondary); }
+.multi-select-option input[type=checkbox] { margin: 0; cursor: pointer; }
+.clear-filters { font-size: 12px; color: var(--link-color); cursor: pointer; margin-left: 4px; }
+
+/* ── Criticality labels (colored square + text) ──────────── */
+.crit-label { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; white-space: nowrap; }
+.crit-square { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
+.crit-square-mandatory { background: var(--color-mandatory); }
+.crit-square-potential { background: var(--color-potential); }
+.crit-square-optional { background: var(--color-optional); }
+
+/* ── Expandable rows ─────────────────────────────────────── */
+.expand-btn { background: none; border: none; cursor: pointer; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 11px; transition: transform 0.15s; padding: 0; vertical-align: middle; flex-shrink: 0; }
+.expand-btn.open { transform: rotate(90deg); }
+.issue-title-cell { display: flex; align-items: center; gap: 4px; }
+.detail-row td { padding: 0; border-bottom: 1px solid var(--border-light); white-space: normal; overflow: visible; text-overflow: clip; }
+.detail-content { display: flex; gap: 0; padding: 8px 8px 8px 32px; min-width: 0; }
+.file-list { flex: 0 0 50%; min-width: 0; overflow: hidden; }
+.file-list table { width: 100%; border-collapse: collapse; }
+.file-list th { font-size: calc(13px * 0.875); text-transform: uppercase; font-weight: 600; color: var(--text-secondary); padding: 6px 8px; border-bottom: 1px solid var(--border-color); text-align: left; height: 32px; }
+.file-list td { font-size: 13px; padding: 6px 8px; border-bottom: 1px solid var(--border-light); color: var(--text-primary); }
+.file-list .file-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; display: block; }
+.file-list .position { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }
+.explanation-panel { flex: 1; padding-left: 16px; min-width: 0; overflow-wrap: break-word; word-wrap: break-word; }
+.explanation-panel h4 { font-size: calc(13px * 0.875); text-transform: uppercase; font-weight: 600; color: var(--text-secondary); padding: 6px 0; border-bottom: 1px solid var(--border-color); height: 32px; margin: 0; }
+.explanation-panel p { font-size: 13px; padding: 8px 0; color: var(--text-primary); line-height: 1.6; white-space: normal; word-break: break-word; }
+
+/* ── Experimental badge ──────────────────────────────────── */
+.badge-experimental { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: #fef9c3; color: #854d0e; vertical-align: middle; margin-left: 8px; cursor: default; }
+
+/* ── Footer ──────────────────────────────────────────────── */
+.footer { text-align: center; margin-top: 24px; color: var(--text-muted); font-size: 13px; }
+
+/* ── Responsive ──────────────────────────────────────────── */
+@media (max-width: 800px) {
+ .main { padding: 16px; }
+ .app-info-container { flex-direction: column; }
+ .domain-summary-container { flex-direction: column; }
+ .domain-summary-item { border-right: none; border-bottom: 1px solid var(--border-light); padding-bottom: 12px; }
+ .detail-content { flex-direction: column; }
+ .file-list { flex: none; width: 100%; }
+ .explanation-panel { padding-left: 0; padding-top: 8px; }
+}
+/* ── Tab navigation ──────────────────────────────────────── */
+.tab-nav { display: flex; gap: 0; border-bottom: 1px solid var(--border-color); margin-bottom: 28px; flex-wrap: wrap; }
+.tab-btn { background: none; border: none; border-bottom: 2px solid transparent; padding: 10px 18px; font-size: 13px; font-family: var(--font-family); color: var(--text-secondary); cursor: pointer; margin-bottom: -1px; white-space: nowrap; position: relative; }
+.tab-btn:hover { color: var(--text-primary); }
+.tab-btn.active { color: var(--link-color); border-bottom-color: var(--link-color); font-weight: 600; }
+.tab-panel { display: none; }
+.tab-panel.active { display: block; }
+
+/* ── Tooltip on tab ──────────────────────────────────────── */
+.tab-btn[data-tooltip]::after { content: attr(data-tooltip); position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: #24292f; color: #ffffff; font-size: 12px; font-weight: 400; padding: 6px 10px; border-radius: 4px; white-space: normal; width: max-content; max-width: 260px; text-align: center; pointer-events: none; opacity: 0; transition: opacity 0.15s; z-index: 100; }
+.tab-btn[data-tooltip]:hover::after { opacity: 1; }
+
+/* ── Fact content — prose column ─────────────────────────── */
+.fact-content { max-width: 860px; margin: 0 auto; line-height: 1.75; color: var(--text-primary); font-size: 14px; }
+.fact-content h1 { font-size: 22px; font-weight: 700; margin: 0 0 6px 0; color: var(--text-primary); letter-spacing: -0.01em; }
+.fact-content h1 + p { margin-top: 6px; color: var(--text-secondary); font-size: 14px; margin-bottom: 24px; }
+.fact-content h2 { font-size: 17px; font-weight: 700; margin: 36px 0 12px 0; padding-bottom: 6px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); letter-spacing: -0.01em; }
+.fact-content h3 { font-size: 14px; font-weight: 700; margin: 24px 0 8px 0; color: var(--text-primary); text-transform: uppercase; letter-spacing: 0.04em; font-size: 12px; color: var(--text-secondary); }
+.fact-content p { margin: 10px 0; color: var(--text-primary); }
+.fact-content ul, .fact-content ol { margin: 10px 0 10px 22px; }
+.fact-content li { margin: 5px 0; }
+.fact-content a { color: var(--link-color); }
+.fact-content a:hover { text-decoration: underline; }
+
+/* ── Fact tables ─────────────────────────────────────────── */
+.table-wrap { overflow-x: auto; margin: 16px 0; }
+.fact-content table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; border-radius: 6px; overflow: hidden; border: 1px solid var(--border-color); }
+.fact-content thead { background: var(--bg-card); }
+.fact-content th { text-align: left; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; padding: 8px 14px; border-bottom: 1px solid var(--border-color); color: var(--text-secondary); }
+.fact-content td { padding: 9px 14px; border-bottom: 1px solid var(--border-light); vertical-align: top; color: var(--text-primary); }
+.fact-content tr:last-child td { border-bottom: none; }
+.fact-content tbody tr:hover { background: #f6f8fa; }
+
+/* ── Fact code ───────────────────────────────────────────── */
+.fact-content code { font-family: var(--font-mono); font-size: 12px; background: #f0f1f3; padding: 2px 6px; border-radius: 4px; color: #c7254e; }
+.fact-content pre { background: #f6f8fa; border: 1px solid var(--border-light); border-radius: 6px; padding: 14px 16px; overflow-x: auto; margin: 14px 0; }
+.fact-content pre code { background: none; color: var(--text-primary); padding: 0; border-radius: 0; }
+.fact-content hr { border: none; border-top: 1px solid var(--border-light); margin: 28px 0; }
+.fact-content strong { font-weight: 600; }
+.fact-content em { font-style: italic; }
+.fact-content blockquote { border-left: 3px solid var(--border-color); margin: 12px 0; padding: 4px 16px; color: var(--text-secondary); }
+.fact-unavailable { padding: 32px 0; }
+.fact-unavailable h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); text-transform: none; letter-spacing: 0; margin: 0 0 12px 0; }
+.fact-unavailable p { margin: 0 0 8px 0; color: var(--text-secondary); }
+.fact-unavailable ol, .fact-unavailable ul { margin: 0 0 0 22px; color: var(--text-primary); }
+.fact-unavailable li { margin: 6px 0; }
+
+/* ── Mermaid diagram card ─────────────────────────────────── */
+.mermaid { background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 8px; padding: 24px 16px; margin: 20px 0; overflow-x: auto; text-align: center; cursor: pointer; position: relative; }
+.mermaid:hover { border-color: var(--border-color); }
+.mermaid svg { max-width: 100%; height: auto !important; display: inline-block; }
+.mermaid-zoom-hint { position: absolute; top: 8px; right: 10px; font-size: 11px; color: var(--text-muted); opacity: 0; transition: opacity 0.15s; pointer-events: none; }
+.mermaid:hover .mermaid-zoom-hint { opacity: 1; }
+
+/* ── Diagram lightbox ────────────────────────────────────── */
+.diagram-lightbox { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,0.7); backdrop-filter: blur(2px); align-items: center; justify-content: center; cursor: zoom-out; padding: 48px 32px; }
+.diagram-lightbox.open { display: flex; }
+.diagram-lightbox-inner { background: #ffffff; border-radius: 10px; padding: 32px; width: 88vw; max-height: 88vh; overflow: hidden; cursor: default; box-shadow: 0 24px 64px rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center; }
+.diagram-lightbox-inner svg { width: 100% !important; height: auto !important; max-height: 76vh; display: block; }
+.diagram-lightbox-close { position: fixed; top: 20px; right: 24px; background: #ffffff; border: none; border-radius: 50%; width: 36px; height: 36px; font-size: 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--text-primary); box-shadow: 0 2px 8px rgba(0,0,0,0.2); z-index: 1001; }
+.diagram-lightbox-close:hover { background: var(--bg-card); }
+.diagram-lightbox-hint { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.55); color: #ffffff; font-size: 12px; padding: 6px 14px; border-radius: 20px; pointer-events: none; white-space: nowrap; z-index: 1001; }"""
+
+# ── Mermaid head script ──────────────────────────────────────────────────────
+MERMAID_HEAD_SCRIPT = """
+"""
+
+# ── JavaScript ───────────────────────────────────────────────────────────────
+MAIN_SCRIPT = r"""function toggleRow(rowId, triggerRow) {
+ var detail = document.getElementById(rowId);
+ var btn = document.getElementById('btn-' + rowId);
+ if (!detail) return;
+ var visible = detail.style.display !== 'none';
+ detail.style.display = visible ? 'none' : 'table-row';
+ if (btn) btn.classList.toggle('open', !visible);
+}
+(function() {
+ var nav = document.querySelector('.tab-nav');
+ if (!nav) return;
+ nav.addEventListener('click', function(e) {
+ var btn = e.target.closest('.tab-btn');
+ if (!btn) return;
+ var targetId = btn.getAttribute('data-tab');
+ document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
+ document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
+ btn.classList.add('active');
+ var panel = document.getElementById(targetId);
+ if (panel) {
+ panel.classList.add('active');
+ if (window.__mermaidReady) { window.__renderMermaidIn(panel); }
+ }
+ });
+})();
+
+// ── Diagram lightbox ────────────────────────────────────────
+(function() {
+ var lightbox = document.getElementById('diagram-lightbox');
+ var inner = document.getElementById('diagram-lightbox-inner');
+ var closeBtn = document.getElementById('diagram-lightbox-close');
+ if (!lightbox || !inner || !closeBtn) return;
+
+ var scale = 1;
+ var translateX = 0;
+ var translateY = 0;
+ var isDragging = false;
+ var dragStartX = 0;
+ var dragStartY = 0;
+
+ function applyTransform() {
+ var svg = inner.querySelector('svg');
+ if (svg) {
+ svg.style.transform = 'translate(' + translateX + 'px, ' + translateY + 'px) scale(' + scale + ')';
+ svg.style.transformOrigin = 'center center';
+ svg.style.transition = 'none';
+ }
+ }
+
+ function resetTransform() {
+ scale = 1; translateX = 0; translateY = 0;
+ applyTransform();
+ }
+
+ function openLightbox(svgEl) {
+ inner.innerHTML = svgEl.outerHTML;
+ var injected = inner.querySelector('svg');
+ if (injected) {
+ injected.removeAttribute('width');
+ injected.removeAttribute('height');
+ injected.style.cursor = 'grab';
+ }
+ resetTransform();
+ lightbox.classList.add('open');
+ document.body.style.overflow = 'hidden';
+ }
+
+ function closeLightbox() {
+ lightbox.classList.remove('open');
+ inner.innerHTML = '';
+ document.body.style.overflow = '';
+ resetTransform();
+ }
+
+ inner.addEventListener('wheel', function(e) {
+ e.preventDefault();
+ var delta = e.deltaY > 0 ? 0.9 : 1.1;
+ scale = Math.min(Math.max(scale * delta, 0.3), 8);
+ applyTransform();
+ }, { passive: false });
+
+ inner.addEventListener('mousedown', function(e) {
+ if (e.button !== 0) return;
+ isDragging = true;
+ dragStartX = e.clientX - translateX;
+ dragStartY = e.clientY - translateY;
+ var svg = inner.querySelector('svg');
+ if (svg) { svg.style.cursor = 'grabbing'; }
+ e.preventDefault();
+ });
+
+ document.addEventListener('mousemove', function(e) {
+ if (!isDragging) return;
+ translateX = e.clientX - dragStartX;
+ translateY = e.clientY - dragStartY;
+ applyTransform();
+ });
+
+ document.addEventListener('mouseup', function() {
+ if (!isDragging) return;
+ isDragging = false;
+ var svg = inner.querySelector('svg');
+ if (svg) { svg.style.cursor = 'grab'; }
+ });
+
+ inner.addEventListener('dblclick', function() { resetTransform(); });
+
+ document.addEventListener('click', function(e) {
+ var card = e.target.closest('.mermaid');
+ if (!card) return;
+ if (e.target.closest('.diagram-lightbox-close')) return;
+ var svg = card.querySelector('svg');
+ if (svg) { openLightbox(svg); }
+ });
+
+ closeBtn.addEventListener('click', closeLightbox);
+
+ lightbox.addEventListener('click', function(e) {
+ if (e.target === lightbox) { closeLightbox(); }
+ });
+
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') { closeLightbox(); }
+ });
+})();
+
+var _currentTarget = '';
+
+function toggleMultiSelect(id) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ document.querySelectorAll('.multi-select.open').forEach(function(ms) { if (ms.id !== id) ms.classList.remove('open'); });
+ el.classList.toggle('open');
+}
+
+document.addEventListener('click', function(e) {
+ if (!e.target.closest('.multi-select')) {
+ document.querySelectorAll('.multi-select.open').forEach(function(ms) { ms.classList.remove('open'); });
+ }
+});
+
+function getMultiSelectValues(msId) {
+ var el = document.getElementById(msId);
+ if (!el) return [];
+ var checked = el.querySelectorAll('input[type=checkbox]:checked');
+ var vals = [];
+ for (var i = 0; i < checked.length; i++) vals.push(checked[i].value);
+ return vals;
+}
+
+function onMultiSelectChange(msId, label) {
+ var el = document.getElementById(msId);
+ if (!el) return;
+ var vals = getMultiSelectValues(msId);
+ var total = el.querySelectorAll('input[type=checkbox]').length;
+ var btn = el.querySelector('.multi-select-btn');
+ if (btn) {
+ btn.textContent = vals.length > 0 && vals.length < total ? label + ' (' + vals.length + ')' : label;
+ }
+ applyFilters();
+}
+
+function applyFilters() {
+ var selectedDomains = getMultiSelectValues('domain-ms');
+ var selectedCrits = getMultiSelectValues('criticality-ms');
+
+ var sections = document.querySelectorAll('.issue-section');
+ for (var i = 0; i < sections.length; i++) {
+ var domain = sections[i].getAttribute('data-domain');
+ sections[i].style.display = (selectedDomains.length === 0 || selectedDomains.indexOf(domain) >= 0) ? '' : 'none';
+ }
+
+ var rows = document.querySelectorAll('.issue-row');
+ for (var i = 0; i < rows.length; i++) {
+ var row = rows[i];
+ var section = row.closest('.issue-section');
+ if (section && section.style.display === 'none') {
+ row.style.display = 'none';
+ var next = row.nextElementSibling;
+ if (next && next.classList.contains('detail-row')) next.style.display = 'none';
+ continue;
+ }
+
+ var show = true;
+ if (_currentTarget && row.hasAttribute('data-targets')) {
+ var targetsStr = row.getAttribute('data-targets');
+ if (targetsStr && targetsStr !== '{}') {
+ try {
+ var targets = JSON.parse(targetsStr);
+ if (Object.keys(targets).length > 0 && !targets[_currentTarget]) {
+ show = false;
+ }
+ } catch(e) {}
+ }
+ }
+
+ if (show && selectedCrits.length > 0) {
+ var crit = row.getAttribute('data-criticality');
+ if (selectedCrits.indexOf(crit) < 0) show = false;
+ }
+
+ row.style.display = show ? '' : 'none';
+ var nextRow = row.nextElementSibling;
+ if (nextRow && nextRow.classList.contains('detail-row')) {
+ if (!show) nextRow.style.display = 'none';
+ }
+ }
+}
+
+function clearAllFilters() {
+ document.querySelectorAll('.multi-select input[type=checkbox]').forEach(function(cb) { cb.checked = false; });
+ var domainBtn = document.querySelector('#domain-ms .multi-select-btn');
+ if (domainBtn) domainBtn.textContent = 'Domain';
+ var critBtn = document.querySelector('#criticality-ms .multi-select-btn');
+ if (critBtn) critBtn.textContent = 'Criticality';
+ applyFilters();
+}
+
+function onTargetChange(targetId) {
+ _currentTarget = targetId;
+ var rows = document.querySelectorAll('.issue-row[data-targets]');
+ var totalEffort = 0;
+ var domainCrits = {};
+
+ for (var i = 0; i < rows.length; i++) {
+ var row = rows[i];
+ var targets = JSON.parse(row.getAttribute('data-targets'));
+ var section = row.closest('.issue-section');
+ var domain = section ? section.getAttribute('data-domain') : '';
+
+ if (!targets || Object.keys(targets).length === 0) {
+ var sp = parseFloat(row.querySelector('.sp-cell').textContent) || 0;
+ totalEffort += sp;
+ if (!domainCrits[domain]) domainCrits[domain] = {mandatory:0,potential:0,optional:0};
+ var crit = row.getAttribute('data-criticality') || 'optional';
+ domainCrits[domain][crit] = (domainCrits[domain][crit] || 0) + 1;
+ continue;
+ }
+
+ var override = targets[targetId];
+ if (!override) {
+ continue;
+ }
+
+ var newSeverity = (override.severity || row.getAttribute('data-default-severity') || '').toLowerCase();
+ row.setAttribute('data-criticality', newSeverity);
+ var critCell = row.querySelector('.crit-cell');
+ if (critCell) {
+ var critText = newSeverity === 'mandatory' ? 'Mandatory' : newSeverity === 'potential' ? 'Potential' : 'Optional';
+ var cssClass = newSeverity === 'mandatory' ? 'crit-square-mandatory' : newSeverity === 'potential' ? 'crit-square-potential' : 'crit-square-optional';
+ critCell.innerHTML = ' ' + critText + ' ';
+ }
+
+ var newEffort = override.effort !== undefined ? override.effort : parseFloat(row.getAttribute('data-default-effort')) || 0;
+ var spCell = row.querySelector('.sp-cell');
+ if (spCell) spCell.textContent = newEffort;
+ totalEffort += newEffort;
+
+ if (!domainCrits[domain]) domainCrits[domain] = {mandatory:0,potential:0,optional:0};
+ domainCrits[domain][newSeverity] = (domainCrits[domain][newSeverity] || 0) + 1;
+ }
+
+ var secSection = document.querySelector('.issue-section[data-domain="security"]');
+ if (secSection) {
+ var secRows = secSection.querySelectorAll('.issue-row');
+ for (var j = 0; j < secRows.length; j++) {
+ if (!secRows[j].hasAttribute('data-targets') || secRows[j].getAttribute('data-targets') === '') {
+ var sp = parseFloat(secRows[j].querySelector('.sp-cell').textContent) || 0;
+ totalEffort += sp;
+ if (!domainCrits['security']) domainCrits['security'] = {mandatory:0,potential:0,optional:0};
+ var sc = secRows[j].getAttribute('data-criticality') || 'optional';
+ domainCrits['security'][sc] = (domainCrits['security'][sc] || 0) + 1;
+ }
+ }
+ }
+
+ var label = totalEffort < 20 ? 'S' : totalEffort < 50 ? 'M' : totalEffort < 100 ? 'L' : 'XL';
+ var effortEl = document.getElementById('effort-display');
+ if (effortEl) effortEl.textContent = label + ' (total story points: ' + totalEffort + ')';
+
+ updateDonuts(domainCrits);
+ applyFilters();
+}
+
+function updateDonuts(domainCrits) {
+ var container = document.getElementById('donut-container');
+ if (!container) return;
+ container.innerHTML = '';
+ var domainNames = {'cloud-readiness':'Cloud Readiness','java-upgrade':'Java Upgrade','security':'Security'};
+ var domainOrder = ['cloud-readiness','java-upgrade','security'];
+ for (var d = 0; d < domainOrder.length; d++) {
+ var key = domainOrder[d];
+ var c = domainCrits[key];
+ if (!c || (c.mandatory + c.potential + c.optional) === 0) continue;
+ var total = c.mandatory + c.potential + c.optional;
+ var div = document.createElement('div');
+ div.className = 'domain-summary-item';
+ div.innerHTML = buildDonutSvg(c.mandatory, c.potential, c.optional) +
+ '' + domainNames[key] + ' ' +
+ '' + total + ' issue' + (total !== 1 ? 's' : '') + '
';
+ container.appendChild(div);
+ }
+}
+
+function buildDonutSvg(m, p, o) {
+ var total = m + p + o;
+ if (total === 0) return ' ';
+ var r = 35, circ = 2 * Math.PI * r, offset = 0;
+ var svg = '';
+ var segs = [{c:m,color:'var(--color-mandatory)'},{c:p,color:'var(--color-potential)'},{c:o,color:'var(--color-optional)'}];
+ for (var i = 0; i < segs.length; i++) {
+ if (segs[i].c === 0) continue;
+ var segLen = (segs[i].c / total) * circ;
+ var gap = total > segs[i].c ? 1.5 : 0;
+ var drawLen = Math.max(segLen - gap, 0.5);
+ svg += ' ';
+ offset += segLen;
+ }
+ svg += ' ';
+ return svg;
+}"""
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+def get_severity_rank(severity):
+ if not severity:
+ return 99
+ s = severity.strip().lower()
+ return {"mandatory": 0, "potential": 1, "optional": 2}.get(s, 99)
+
+
+def build_donut_svg(mandatory, potential, optional):
+ total = mandatory + potential + optional
+ if total == 0:
+ return (''
+ ' '
+ ' \n')
+ r = 35
+ circ = 2 * math.pi * r
+ segments = [
+ (mandatory, "var(--color-mandatory)"),
+ (potential, "var(--color-potential)"),
+ (optional, "var(--color-optional)"),
+ ]
+ svg = '\n'
+ offset = 0.0
+ for count, color in segments:
+ if count == 0:
+ continue
+ seg_len = (count / total) * circ
+ gap = 1.5 if total > count else 0
+ draw_len = max(seg_len - gap, 0.5)
+ svg += (f' \n')
+ offset += seg_len
+ svg += ' \n'
+ return svg
+
+
+def simple_markdown_to_html(md):
+ """Convert rule description markdown to inline HTML."""
+ if not md:
+ return ""
+ html = escape(md)
+ # Code blocks
+ html = re.sub(r'```\w*\r?\n([\s\S]*?)```', r'\1 ', html)
+ # Inline code
+ html = re.sub(r'`([^`]+)`', r'\1', html)
+ # Bold
+ html = re.sub(r'\*\*(.+?)\*\*', r'\1 ', html)
+ # Links
+ def replace_link(m):
+ text, url = m.group(1), m.group(2)
+ return f'{text} '
+ html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', replace_link, html)
+
+ def apply_italic(text):
+ return re.sub(r'\*(.+?)\*', r'\1 ', text)
+
+ # Process line-by-line for block structures
+ lines = re.split(r'\r?\n', html)
+ result = []
+ in_list = False
+ in_paragraph = False
+
+ for line in lines:
+ # List item
+ list_match = re.match(r'^[\-\*]\s+(.+)', line)
+ if list_match:
+ if in_paragraph:
+ result.append('
')
+ in_paragraph = False
+ if not in_list:
+ result.append('')
+ in_list = True
+ content = apply_italic(list_match.group(1))
+ result.append(f'{content} ')
+ continue
+
+ # End list if needed
+ if in_list:
+ result.append(' ')
+ in_list = False
+
+ # Blank line
+ if not line.strip():
+ if in_paragraph:
+ result.append('')
+ in_paragraph = False
+ continue
+
+ # Regular text
+ text_content = apply_italic(line)
+ if not in_paragraph:
+ result.append('')
+ in_paragraph = True
+ else:
+ result.append(' ')
+ result.append(text_content)
+
+ if in_list:
+ result.append('')
+ if in_paragraph:
+ result.append('
')
+
+ return ''.join(result)
+
+
+def markdown_to_fact_html(md):
+ """Convert a fact .md file to HTML for the fact-content div."""
+ if not md:
+ return ""
+ lines = md.split('\n')
+ out = []
+ i = 0
+ in_list = None # 'ul' or 'ol'
+ in_table = False
+ table_lines = []
+
+ def flush_table():
+ nonlocal in_table, table_lines
+ if not table_lines:
+ return
+ # Parse markdown table
+ header = table_lines[0]
+ # table_lines[1] is the separator
+ rows = table_lines[2:] if len(table_lines) > 2 else []
+ cols = [c.strip() for c in header.strip('|').split('|')]
+ out.append('')
+ out.append(''.join(f'{inline_md(c)} ' for c in cols))
+ out.append(' ')
+ for row in rows:
+ cells = [c.strip() for c in row.strip('|').split('|')]
+ out.append('' + ''.join(f'{inline_md(c)} ' for c in cells) + ' ')
+ out.append('
')
+ table_lines = []
+ in_table = False
+
+ def flush_list():
+ nonlocal in_list
+ if in_list:
+ out.append(f'{in_list}>')
+ in_list = None
+
+ def inline_md(text):
+ """Convert inline markdown."""
+ t = text
+ t = re.sub(r'`([^`]+)`', r'\1', t)
+ t = re.sub(r'\*\*(.+?)\*\*', r'\1 ', t)
+ t = re.sub(r'\*(.+?)\*', r'\1 ', t)
+ t = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 ', t)
+ return t
+
+ while i < len(lines):
+ line = lines[i]
+
+ # Mermaid code block
+ if line.strip().startswith('```mermaid'):
+ flush_list()
+ flush_table()
+ i += 1
+ mermaid_lines = []
+ while i < len(lines) and not lines[i].strip().startswith('```'):
+ mermaid_lines.append(lines[i])
+ i += 1
+ i += 1 # skip closing ```
+ out.append('')
+ out.append('\n'.join(mermaid_lines))
+ out.append('
')
+ continue
+
+ # Regular code block
+ if line.strip().startswith('```'):
+ flush_list()
+ flush_table()
+ i += 1
+ code_lines = []
+ while i < len(lines) and not lines[i].strip().startswith('```'):
+ code_lines.append(escape(lines[i]))
+ i += 1
+ i += 1
+ out.append('')
+ out.append('\n'.join(code_lines))
+ out.append(' ')
+ continue
+
+ # Table detection
+ if '|' in line and i + 1 < len(lines) and re.match(r'^\s*\|?[\s\-:|]+\|', lines[i + 1]):
+ flush_list()
+ if not in_table:
+ in_table = True
+ table_lines = []
+ table_lines.append(line)
+ i += 1
+ continue
+ if in_table and '|' in line:
+ table_lines.append(line)
+ i += 1
+ continue
+ if in_table:
+ flush_table()
+
+ # Headers
+ m = re.match(r'^(#{1,3})\s+(.+)', line)
+ if m:
+ flush_list()
+ level = len(m.group(1))
+ text = m.group(2).strip()
+ tag = f'h{level}'
+ hid = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
+ out.append(f'<{tag} id="{hid}">{inline_md(text)}{tag}>')
+ i += 1
+ continue
+
+ # Unordered list
+ m = re.match(r'^[\-\*]\s+(.+)', line)
+ if m:
+ flush_table()
+ if in_list != 'ul':
+ flush_list()
+ in_list = 'ul'
+ out.append('')
+ out.append(f'{inline_md(m.group(1))} ')
+ i += 1
+ continue
+
+ # Ordered list
+ m = re.match(r'^\d+\.\s+(.+)', line)
+ if m:
+ flush_table()
+ if in_list != 'ol':
+ flush_list()
+ in_list = 'ol'
+ out.append('')
+ out.append(f'{inline_md(m.group(1))} ')
+ i += 1
+ continue
+
+ # Blank line
+ if not line.strip():
+ flush_list()
+ i += 1
+ continue
+
+ # Paragraph
+ flush_list()
+ out.append(f'{inline_md(line)}
')
+ i += 1
+
+ flush_list()
+ flush_table()
+ return '\n'.join(out)
+
+
+def is_dotnet_component(language):
+ if not language or language.strip().upper() == "N/A":
+ return True
+ return ".net" in language.lower() or "c#" in language.lower()
+
+
+# ── Fact tabs catalog ────────────────────────────────────────────────────────
+FACT_TABS = [
+ ("tab-arch", "Architecture", "How the app's layers connect — from UI to data.", "architecture-diagram.md"),
+ ("tab-api", "API Contracts", "Endpoints exposed by this app and how services communicate.", "api-service-contracts.md"),
+ ("tab-config", "Configuration", "Environment settings, runtime profiles, and deployment manifests.", "configuration-inventory.md"),
+ ("tab-workflows", "Business Workflows", "Domain entities and the core flows users move through.", "business-workflows.md"),
+ ("tab-deps", "Dependencies", "Libraries and frameworks this app depends on, grouped by category.", "dependency-map.md"),
+ ("tab-data", "Data Model", "Database schema, entity relationships, and persistence behavior.", "data-architecture.md"),
+]
+
+
+# ── Main generation logic ────────────────────────────────────────────────────
+
+def generate_from_report_json(report_dir):
+ """Generate report.html from report.json (Java/.NET)."""
+ report_json_path = os.path.join(report_dir, "report.json")
+ if not os.path.isfile(report_json_path):
+ print(f"Error: report.json not found at {report_json_path}", file=sys.stderr)
+ sys.exit(1)
+
+ with open(report_json_path, 'r', encoding='utf-8') as f:
+ root = json.load(f)
+
+ # Parse metadata
+ metadata = root.get("metadata", {})
+ target_display_names = metadata.get("targetDisplayNames", [])
+ target_ids = metadata.get("targetIds", [])
+
+ # Parse rules
+ rules = root.get("rules", {})
+
+ # Parse projects/incidents
+ projects = root.get("projects", [])
+ # Merge all projects into one component view
+ component_name = ""
+ component_display_name = ""
+ language = "N/A"
+ frameworks = "N/A"
+ build_tools = "N/A"
+ jdk_version = ""
+ all_incidents = []
+
+ for proj in projects:
+ props = proj.get("properties", {})
+ if not component_name:
+ component_name = props.get("repo", "") or props.get("appName", "Application")
+ component_display_name = props.get("appName", "") or component_name
+ lang = props.get("languages")
+ if lang:
+ language = ", ".join(lang) if isinstance(lang, list) else str(lang)
+ fw = props.get("frameworks")
+ if fw:
+ frameworks = ", ".join(fw) if isinstance(fw, list) else str(fw)
+ tools = props.get("tools")
+ if tools:
+ build_tools = ", ".join(tools) if isinstance(tools, list) else str(tools)
+ if not jdk_version:
+ jdk_version = props.get("jdkVersion", "")
+
+ for incident in proj.get("incidents", []):
+ rule_id = incident.get("ruleId", "")
+ if rule_id and rule_id in rules:
+ all_incidents.append(incident)
+
+ # Parse security findings
+ security_findings = root.get("security", [])
+ # Parse rearchitect findings
+ rearchitect_findings = root.get("rearchitect", [])
+
+ is_dotnet = is_dotnet_component(language)
+
+ # Group incidents by rule
+ incidents_by_rule = {}
+ for inc in all_incidents:
+ rid = inc.get("ruleId", "")
+ if rid not in incidents_by_rule:
+ incidents_by_rule[rid] = []
+ incidents_by_rule[rid].append(inc)
+
+ # Classify into cloud/upgrade domains
+ cloud_groups = [] # list of (rule_id, incidents_list)
+ upgrade_groups = []
+ upgrade_domain_name = "Upgrade"
+
+ for rule_id, incs in incidents_by_rule.items():
+ rule_obj = rules.get(rule_id, {})
+ labels = rule_obj.get("labels", [])
+ has_domain = any(l.startswith("domain=") for l in labels)
+ is_cloud = any(l == "domain=cloud-readiness" for l in labels)
+ is_upgrade = any(l.endswith("-upgrade") for l in labels)
+
+ if not has_domain and is_dotnet:
+ is_cloud = True
+
+ if is_cloud:
+ cloud_groups.append((rule_id, incs))
+ if is_upgrade:
+ upgrade_groups.append((rule_id, incs))
+ upgrade_label = next((l for l in labels if l.endswith("-upgrade") and l.startswith("domain=")), None)
+ if upgrade_label:
+ domain_val = upgrade_label[len("domain="):]
+ upgrade_domain_name = " ".join(w.capitalize() for w in domain_val.split("-"))
+
+ # Count criticalities per domain
+ def count_crits(groups):
+ m, p, o = 0, 0, 0
+ for rid, _ in groups:
+ sev = rules.get(rid, {}).get("severity", "").strip().lower()
+ if sev == "mandatory": m += 1
+ elif sev == "potential": p += 1
+ else: o += 1
+ return m, p, o
+
+ cloud_crit = count_crits(cloud_groups)
+ upgrade_crit = count_crits(upgrade_groups)
+
+ # Add rearchitect to upgrade optional count
+ if rearchitect_findings:
+ upgrade_crit = (upgrade_crit[0], upgrade_crit[1], upgrade_crit[2] + len(rearchitect_findings))
+
+ sec_mandatory = sum(1 for f in security_findings if f.get("severity", "").strip().lower() == "mandatory")
+ sec_potential = sum(1 for f in security_findings if f.get("severity", "").strip().lower() == "potential")
+ sec_optional = len(security_findings) - sec_mandatory - sec_potential
+ security_crit = (sec_mandatory, sec_potential, sec_optional)
+
+ # Compute total effort
+ # For each unique rule, take aksEffort from first incident
+ total_effort = 0.0
+ seen_rules = set()
+ for inc in all_incidents:
+ rid = inc.get("ruleId", "")
+ if rid in seen_rules:
+ continue
+ seen_rules.add(rid)
+ # Get AKS effort
+ aks_effort = 0
+ targets = inc.get("targets", {})
+ for tid, tdata in targets.items():
+ if "aks" in tid.lower() or "kubernetes" in tid.lower():
+ aks_effort = tdata.get("effort", 0)
+ break
+ if aks_effort == 0:
+ aks_effort = rules.get(rid, {}).get("effort", 0) or 0
+ total_effort += aks_effort
+
+ total_effort += sum(f.get("storyPoint", 0) for f in security_findings)
+ total_effort += len(rearchitect_findings) * 10
+
+ if total_effort < 20:
+ effort_label = "S"
+ elif total_effort < 50:
+ effort_label = "M"
+ elif total_effort < 100:
+ effort_label = "L"
+ else:
+ effort_label = "XL"
+ effort_display = f"{effort_label} (total story points: {int(total_effort)})"
+
+ # Load fact tabs
+ facts_dir = os.path.join(report_dir, "facts")
+ # Fallback: if facts/ doesn't have the files, try engines/ directory
+ engines_dir = os.path.join(os.path.dirname(os.path.dirname(report_dir)), "engines")
+ fact_tab_data = [] # (tab_id, label, tooltip, html_content)
+ for tab_id, label, tooltip, filename in FACT_TABS:
+ fpath = os.path.join(facts_dir, filename)
+ if not os.path.isfile(fpath):
+ # Try engines/ as fallback
+ fpath = os.path.join(engines_dir, filename)
+ if os.path.isfile(fpath):
+ with open(fpath, 'r', encoding='utf-8') as f:
+ md_content = f.read()
+ html_content = markdown_to_fact_html(md_content)
+ fact_tab_data.append((tab_id, label, tooltip, html_content))
+
+ # ── Build HTML ───────────────────────────────────────────────────────────
+ html = []
+ html.append('')
+ html.append(' ')
+ html.append(' ')
+ html.append(f'Assessment - {escape(component_display_name or component_name)} ')
+ html.append('')
+ html.append(MERMAID_HEAD_SCRIPT)
+ html.append('')
+
+ # Lightbox
+ html.append('')
+ html.append('
✕ ')
+ html.append('
Scroll to zoom · Drag to pan · Double-click to reset
')
+ html.append('
')
+ html.append('
')
+
+ html.append('')
+ html.append(f'
{escape(component_display_name or component_name)} ')
+
+ # App info card
+ html.append('
')
+ html.append('
Application Information ')
+ html.append('
')
+ html.append('
')
+ html.append('
')
+ html.append(f'
Application Name {escape(component_display_name or component_name)}
')
+ if jdk_version:
+ html.append(f'
Java Version {escape(jdk_version)}
')
+ html.append(f'
Effort {escape(effort_display)}
')
+ html.append('
')
+ html.append('
')
+ html.append(f'
Build Tools {escape(build_tools)}
')
+ html.append(f'
Frameworks {escape(frameworks)}
')
+ html.append('
')
+ html.append('
')
+ html.append('
')
+ html.append('
')
+
+ # Tab navigation
+ html.append('
')
+ html.append(' Issues ')
+ for tab_id, label, tooltip, _ in fact_tab_data:
+ html.append(f' {escape(label)} ')
+ html.append(' ')
+
+ # Issues tab panel
+ html.append('
')
+
+ # Target dropdown
+ if target_display_names and target_ids:
+ html.append('
')
+ html.append(' Target Compute Service: ')
+ html.append(' ')
+ count = min(len(target_display_names), len(target_ids))
+ for idx in range(count):
+ html.append(f' {escape(target_display_names[idx])} ')
+ html.append(' ')
+ html.append('
')
+
+ # Issue Summary card
+ html.append('
')
+ html.append('
Issue Summary ')
+ html.append('
')
+ html.append('
')
+
+ def render_domain_donut(title, m, p, o):
+ total = m + p + o
+ html.append('
')
+ html.append(build_donut_svg(m, p, o))
+ html.append(f'
{escape(title)} ')
+ html.append(f'
{total} issue{"s" if total != 1 else ""}
')
+ html.append('
')
+
+ if cloud_groups:
+ render_domain_donut("Cloud Readiness", *cloud_crit)
+ if upgrade_groups or rearchitect_findings:
+ render_domain_donut(upgrade_domain_name, *upgrade_crit)
+ if security_findings:
+ render_domain_donut("Security", *security_crit)
+
+ html.append('
')
+ html.append('
')
+ html.append('
Mandatory
')
+ html.append('
Potential
')
+ html.append('
Optional
')
+ html.append('
')
+ html.append('
')
+ html.append('
')
+
+ # Filter bar
+ available_domains = []
+ if cloud_groups:
+ available_domains.append(("cloud-readiness", "Cloud Readiness"))
+ if upgrade_groups:
+ available_domains.append(("java-upgrade", upgrade_domain_name))
+ if security_findings:
+ available_domains.append(("security", "Security"))
+
+ available_crits = set()
+ for rid, _ in cloud_groups + upgrade_groups:
+ sev = rules.get(rid, {}).get("severity", "optional").strip().lower()
+ available_crits.add(sev)
+ for f in security_findings:
+ available_crits.add(f.get("severity", "optional").strip().lower() or "optional")
+
+ html.append('
')
+ if len(available_domains) > 1:
+ html.append('
')
+ html.append('
Domain ')
+ html.append('
')
+ for did, dlabel in available_domains:
+ html.append(f' {escape(dlabel)} ')
+ html.append('
')
+ html.append('
')
+ if len(available_crits) > 1:
+ html.append('
')
+ html.append('
Criticality ')
+ html.append('
')
+ for c in ["mandatory", "potential", "optional"]:
+ if c in available_crits:
+ html.append(f' {c.capitalize()} ')
+ html.append('
')
+ html.append('
')
+ html.append('
Clear all ')
+ html.append('
')
+
+ # Issue sections
+ def render_issue_section(section_title, groups, domain_id, badge_html=None, extra_rearchitect=None):
+ if not groups and not extra_rearchitect:
+ return
+ html.append(f'
')
+ badge = badge_html or ""
+ html.append(f'
{escape(section_title)}{badge} ')
+ html.append('
')
+ html.append(' ')
+ html.append(' Issue Category Criticality Story Point ')
+ html.append(' ')
+
+ # Sort by severity rank then count desc
+ ordered = sorted(groups, key=lambda g: (get_severity_rank(rules.get(g[0], {}).get("severity", "")), -len(g[1])))
+
+ section_no_spaces = section_title.replace(" ", "").lower()
+ for gi, (rule_id, incs) in enumerate(ordered):
+ rule_obj = rules.get(rule_id, {})
+ title = rule_obj.get("title", rule_id)
+ severity = rule_obj.get("severity", "").strip().lower()
+ first_inc = incs[0]
+
+ # Get AKS effort from first incident
+ story_point = 0
+ targets_data = first_inc.get("targets", {})
+ for tid, tdata in targets_data.items():
+ if "aks" in tid.lower() or "kubernetes" in tid.lower():
+ story_point = tdata.get("effort", 0)
+ break
+ if story_point == 0:
+ story_point = rule_obj.get("effort", 0) or 0
+
+ row_id = f"row-{section_no_spaces}-{gi}"
+
+ if severity == "mandatory":
+ crit_text, crit_css = "Mandatory", "crit-square-mandatory"
+ elif severity == "potential":
+ crit_text, crit_css = "Potential", "crit-square-potential"
+ else:
+ crit_text, crit_css = "Optional", "crit-square-optional"
+
+ # Build targets JSON
+ targets_json_obj = {}
+ for tid, tdata in targets_data.items():
+ targets_json_obj[tid] = {"effort": tdata.get("effort", 0), "severity": tdata.get("severity")}
+ targets_json_str = json.dumps(targets_json_obj, separators=(',', ':'))
+
+ html.append(f" ")
+ html.append(f' ❯ {escape(title)}
')
+ html.append(f' {crit_text} ')
+ html.append(f' {story_point} ')
+ html.append(' ')
+
+ # Detail row
+ incidents_with_loc = [inc for inc in incs if inc.get("location")]
+ html.append(f' ')
+ html.append(' ')
+ html.append(' ')
+ html.append('
')
+ html.append('
')
+ html.append(' File Position ')
+ for inc in incidents_with_loc:
+ fp = inc.get("location", "")
+ line = inc.get("line")
+ line_text = f"Line {line}" if line else ""
+ html.append(f' {escape(fp)} {line_text} ')
+ html.append('
')
+ html.append('
')
+
+ description = rule_obj.get("description", "")
+ if description:
+ html.append('
')
+ html.append('
Explanation ')
+ html.append(f'
{simple_markdown_to_html(description)}
')
+ html.append('
')
+
+ html.append('
')
+ html.append(' ')
+ html.append(' ')
+
+ # Rearchitect findings
+ if extra_rearchitect:
+ for ri, finding in enumerate(extra_rearchitect):
+ row_id = f"row-{section_no_spaces}-rearch-{ri}"
+ old_val = finding.get("old", "")
+ explanation = finding.get("explanation", "")
+ detected_in = finding.get("detectedIn", {})
+ all_files = (detected_in.get("configFiles") or []) + (detected_in.get("sourceFiles") or [])
+
+ html.append(f' ')
+ html.append(f' ❯ Framework obsoletion ({escape(old_val)})
')
+ html.append(f' Optional ')
+ html.append(f' 10 ')
+ html.append(' ')
+ html.append(f' ')
+ html.append(' ')
+ html.append(' ')
+ if all_files:
+ html.append('
')
+ html.append('
')
+ html.append(' File Position ')
+ for fp in all_files:
+ html.append(f' {escape(fp)} ')
+ html.append('
')
+ html.append('
')
+ if explanation:
+ html.append('
')
+ html.append('
Explanation ')
+ html.append(f'
{escape(explanation)}
')
+ html.append('
')
+ html.append('
')
+ html.append(' ')
+ html.append(' ')
+
+ html.append(' ')
+ html.append('
')
+ html.append('
')
+
+ render_issue_section("Cloud Readiness", cloud_groups, "cloud-readiness")
+ render_issue_section(upgrade_domain_name, upgrade_groups, "java-upgrade", extra_rearchitect=rearchitect_findings)
+
+ # Security section
+ if security_findings:
+ html.append('
')
+ html.append('
Security Experimental ')
+ html.append('
')
+ html.append(' ')
+ html.append(' Issue Category Criticality Story Point ')
+ html.append(' ')
+
+ ordered_sec = sorted(security_findings, key=lambda f: (get_severity_rank(f.get("severity", "")), -f.get("storyPoint", 0)))
+ for fi, finding in enumerate(ordered_sec):
+ row_id = f"row-security-{fi}"
+ sev = finding.get("severity", "").strip().lower()
+ if sev == "mandatory":
+ crit_text, crit_css = "Mandatory", "crit-square-mandatory"
+ elif sev == "potential":
+ crit_text, crit_css = "Potential", "crit-square-potential"
+ else:
+ crit_text, crit_css = "Optional", "crit-square-optional"
+
+ fid = finding.get("id", "")
+ ftitle = finding.get("title", "")
+ display_name = f"{fid}: {ftitle}" if ftitle else fid
+ sp = finding.get("storyPoint", 0)
+
+ html.append(f' ')
+ html.append(f' ❯ {escape(display_name)}
')
+ html.append(f' {crit_text} ')
+ html.append(f' {sp} ')
+ html.append(' ')
+
+ html.append(f' ')
+ html.append(' ')
+ html.append(' ')
+ html.append('
')
+ html.append('
File ')
+ evidence = finding.get("evidence", {})
+ files = evidence.get("files", []) if isinstance(evidence, dict) else []
+ for fp in files:
+ html.append(f' {escape(fp)} ')
+ html.append('
')
+ html.append('
')
+ desc = finding.get("description", "")
+ if desc:
+ html.append('
')
+ html.append('
Explanation ')
+ html.append(f'
{simple_markdown_to_html(desc)}
')
+ html.append('
')
+ html.append('
')
+ html.append(' ')
+ html.append(' ')
+
+ html.append(' ')
+ html.append('
')
+ html.append('
')
+
+ html.append('
')
+
+ # Fact tab panels
+ for tab_id, _, _, content in fact_tab_data:
+ html.append(f'
')
+ html.append(f'
{content}
')
+ html.append('
')
+
+ # Footer
+ html.append('')
+
+ # Script
+ html.append('')
+
+ # Initial target change
+ if target_ids:
+ html.append(f"")
+
+ html.append('
')
+
+ return '\n'.join(html)
+
+
+def generate_from_js_markdown(report_dir):
+ """Generate report.html from js-assessment-report.md (JS/TS)."""
+ # Look for js-assessment-report.md in report_dir or parent directories
+ md_path = None
+ search_dir = report_dir
+ for _ in range(5):
+ candidate = os.path.join(search_dir, "js-assessment-report.md")
+ if os.path.isfile(candidate):
+ md_path = candidate
+ break
+ search_dir = os.path.dirname(search_dir)
+
+ if not md_path:
+ print("Error: js-assessment-report.md not found", file=sys.stderr)
+ sys.exit(1)
+
+ with open(md_path, 'r', encoding='utf-8') as f:
+ md_content = f.read()
+
+ # Extract title from first heading
+ title_match = re.match(r'^#\s+(.+)', md_content)
+ title = title_match.group(1) if title_match else "Assessment Report"
+
+ fact_html = markdown_to_fact_html(md_content)
+
+ html = []
+ html.append('')
+ html.append(' ')
+ html.append(' ')
+ html.append(f'Assessment - {escape(title)} ')
+ html.append('')
+ html.append(MERMAID_HEAD_SCRIPT)
+ html.append('')
+ html.append('')
+ html.append('
✕ ')
+ html.append('
Scroll to zoom · Drag to pan · Double-click to reset
')
+ html.append('
')
+ html.append('
')
+ html.append('')
+ html.append(f'
{escape(title)} ')
+ html.append(f'
{fact_html}
')
+ html.append('')
+ html.append('')
+ html.append('
')
+
+ return '\n'.join(html)
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: python generate_report_html.py /path/to/report-directory", file=sys.stderr)
+ sys.exit(1)
+
+ report_dir = sys.argv[1]
+ if not os.path.isdir(report_dir):
+ print(f"Error: Directory not found: {report_dir}", file=sys.stderr)
+ sys.exit(1)
+
+ report_json_path = os.path.join(report_dir, "report.json")
+
+ if os.path.isfile(report_json_path):
+ output = generate_from_report_json(report_dir)
+ else:
+ # Look for JS/TS markdown
+ output = generate_from_js_markdown(report_dir)
+
+ output_path = os.path.join(report_dir, "report.html")
+ with open(output_path, 'w', encoding='utf-8') as f:
+ f.write(output)
+
+ print(f"Generated: {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/modernize/assessment/engines/generate-report-md.py b/.github/modernize/assessment/engines/generate-report-md.py
new file mode 100644
index 000000000..2c01c3ed0
--- /dev/null
+++ b/.github/modernize/assessment/engines/generate-report-md.py
@@ -0,0 +1,579 @@
+#!/usr/bin/env python3
+"""
+Generates report.md from report.json (Java/.NET) or js-assessment-report.md (JS/TS).
+
+Usage:
+ python generate_report_md.py /path/to/.github/modernize/assessment/reports/report-{id}
+"""
+
+import json
+import os
+import re
+import sys
+
+
+def make_anchor_id(title: str) -> str:
+ """Convert title to anchor ID: replace non-alnum/non-dash with _, collapse, trim trailing."""
+ if not title:
+ return "issue"
+ result = []
+ last_was_underscore = False
+ for ch in title:
+ if ch.isalnum() or ch == '-':
+ result.append(ch)
+ last_was_underscore = False
+ else:
+ if not last_was_underscore and result:
+ result.append('_')
+ last_was_underscore = True
+ if result and result[-1] == '_':
+ result.pop()
+ return ''.join(result)
+
+
+def escape_table_cell(value: str) -> str:
+ if not value:
+ return ""
+ return value.replace("|", "\\|").replace("\r", "").replace("\n", " ")
+
+
+def escape_html(value: str) -> str:
+ if not value:
+ return ""
+ return value.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
+
+
+def get_array_as_string(node) -> str:
+ """Convert a JSON node (array or string) to comma-separated string."""
+ if node is None:
+ return ""
+ if isinstance(node, list):
+ values = [v for v in node if v and str(v).strip()]
+ return ", ".join(str(v) for v in values) if values else ""
+ if isinstance(node, str):
+ return node if node.strip() else ""
+ return str(node)
+
+
+def get_severity_rank(severity: str) -> int:
+ s = (severity or "").strip().lower()
+ return {"mandatory": 0, "potential": 1, "optional": 2, "information": 3}.get(s, 999)
+
+
+def is_dotnet_component(language: str) -> bool:
+ if not language or language.strip().lower() == "n/a":
+ return True
+ lang_lower = language.lower()
+ return ".net" in lang_lower or "c#" in lang_lower
+
+
+def render_appcat_report(report_dir: str, report_json_path: str) -> str:
+ """Render report.md from report.json for Java/.NET."""
+ with open(report_json_path, 'r', encoding='utf-8') as f:
+ root = json.load(f)
+
+ rules = root.get("rules", {})
+ projects = root.get("projects", [])
+ security_findings = root.get("security", [])
+ rearchitect_findings = root.get("rearchitect", [])
+
+ if not projects:
+ return ""
+
+ # Parse first component
+ project = projects[0]
+ properties = project.get("properties", {}) or {}
+
+ component_name = properties.get("repo") or properties.get("appName") or "Others"
+ language = get_array_as_string(properties.get("languages")) or "N/A"
+ frameworks = get_array_as_string(properties.get("frameworks")) or "N/A"
+ build_tools = get_array_as_string(properties.get("tools")) or "N/A"
+
+ # Merge all projects for same component
+ all_incidents = []
+ for proj in projects:
+ incidents_array = proj.get("incidents", [])
+ for inc in incidents_array:
+ all_incidents.append(inc)
+
+ # Group incidents by ruleId, track unique rules
+ rule_groups = {} # ruleId -> list of incidents
+ for inc in all_incidents:
+ rule_id = inc.get("ruleId", "")
+ if not rule_id or rule_id not in rules:
+ continue
+ rule_groups.setdefault(rule_id, []).append(inc)
+
+ # Compute severity counts (unique rules)
+ mandatory_rules = set()
+ potential_rules = set()
+ optional_rules = set()
+ for rule_id in rule_groups:
+ rule_obj = rules.get(rule_id, {})
+ severity = (rule_obj.get("severity") or "").strip().lower()
+ if severity == "mandatory":
+ mandatory_rules.add(rule_id)
+ elif severity == "potential":
+ potential_rules.add(rule_id)
+ elif severity == "optional":
+ optional_rules.add(rule_id)
+
+ # Security finding counts
+ sec_mandatory = sum(1 for f in security_findings if (f.get("severity") or "").strip().lower() == "mandatory")
+ sec_potential = sum(1 for f in security_findings if (f.get("severity") or "").strip().lower() == "potential")
+
+ total_issues = len(rule_groups) + len(security_findings)
+ mandatory_blockers = len(mandatory_rules) + sec_mandatory
+ potential_issues = len(potential_rules) + sec_potential
+
+ # Classify rules into cloud/upgrade
+ is_dotnet = is_dotnet_component(language)
+ cloud_rules = []
+ upgrade_rules = []
+
+ for rule_id, incidents in rule_groups.items():
+ rule_obj = rules.get(rule_id, {})
+ labels = rule_obj.get("labels", []) or []
+ labels = [l for l in labels if l and str(l).strip()]
+
+ has_domain_label = any(str(l).lower().startswith("domain=") for l in labels)
+ is_cloud = any(str(l).lower() == "domain=cloud-readiness" for l in labels)
+ is_upgrade = any(str(l).lower().endswith("-upgrade") for l in labels)
+
+ if not has_domain_label and is_dotnet:
+ is_cloud = True
+
+ if is_cloud:
+ cloud_rules.append(rule_id)
+ if is_upgrade:
+ upgrade_rules.append(rule_id)
+
+ # Get AKS effort for each rule (first incident's target or rule-level)
+ def get_aks_effort(rule_id: str) -> float:
+ incidents = rule_groups.get(rule_id, [])
+ for inc in incidents:
+ targets = inc.get("targets", {}) or {}
+ for target_id, target_data in targets.items():
+ if isinstance(target_data, dict) and "aks" in target_id.lower():
+ effort = target_data.get("effort", 0)
+ if effort:
+ return float(effort)
+ break
+ # Fallback to rule-level
+ rule_obj = rules.get(rule_id, {})
+ return float(rule_obj.get("effort", 0) or 0)
+
+ def sort_key(rule_id):
+ rule_obj = rules.get(rule_id, {})
+ severity = (rule_obj.get("severity") or "").strip().lower()
+ rank = get_severity_rank(severity)
+ count = len(rule_groups.get(rule_id, []))
+ return (rank, -count)
+
+ cloud_rules.sort(key=sort_key)
+ upgrade_rules.sort(key=sort_key)
+
+ # Build markdown
+ lines = []
+ lines.append(f"# {component_name}")
+ lines.append("")
+ lines.append("## Summary")
+ lines.append("")
+ lines.append("| Metric | Value |")
+ lines.append("|--------|-------|")
+ lines.append(f"| Total Issues | {total_issues} |")
+ lines.append(f"| Mandatory Blockers | {mandatory_blockers} |")
+ lines.append(f"| Potential Issues | {potential_issues} |")
+ lines.append("")
+ lines.append("## Application Information")
+ lines.append("")
+ lines.append("| Property | Value |")
+ lines.append("|----------|-------|")
+ lines.append(f"| Language | {escape_table_cell(language)} |")
+ lines.append(f"| Frameworks | {escape_table_cell(frameworks)} |")
+ lines.append(f"| Build tools | {escape_table_cell(build_tools)} |")
+
+ # Conditional rows
+ jdk_version = properties.get("jdkVersion", "")
+ if jdk_version and str(jdk_version).strip():
+ lines.append(f"| JDK version | {escape_table_cell(str(jdk_version))} |")
+
+ description = properties.get("description", "")
+ if description and str(description).strip():
+ lines.append(f"| Description | {escape_table_cell(str(description))} |")
+
+ app_type = properties.get("applicationType", "")
+ if app_type and str(app_type).strip():
+ lines.append(f"| Application type | {escape_table_cell(str(app_type))} |")
+
+ tags = get_array_as_string(properties.get("tags"))
+ if tags:
+ lines.append(f"| Tags | {escape_table_cell(tags)} |")
+
+ loc = properties.get("linesOfCode", "")
+ if loc and str(loc).strip():
+ lines.append(f"| Lines of code | {escape_table_cell(str(loc))} |")
+
+ # Do NOT include Story points
+ lines.append("")
+
+ def render_issue_section(section_title: str, rule_ids: list):
+ if not rule_ids:
+ return
+ lines.append(f"## {section_title}")
+ lines.append("")
+ lines.append("| Issue Name | Criticality | Story Points | Occurrences |")
+ lines.append("|------------|-------------|--------------|-------------|")
+
+ for rule_id in rule_ids:
+ rule_obj = rules.get(rule_id, {})
+ title = rule_obj.get("title") or rule_id
+ severity = (rule_obj.get("severity") or "").strip().lower()
+ crit_label = {"mandatory": "Mandatory", "potential": "Potential"}.get(severity, "Optional")
+ effort = get_aks_effort(rule_id)
+ # Format effort: integer if whole, else float
+ effort_str = str(int(effort)) if effort == int(effort) else f"{effort:g}"
+ count = len(rule_groups.get(rule_id, []))
+ anchor_id = make_anchor_id(title)
+
+ # Check if any incident has a file path
+ has_locations = any(
+ (inc.get("location") or "").strip()
+ for inc in rule_groups.get(rule_id, [])
+ )
+ occurrences_cell = f"[{count}](#{anchor_id})" if has_locations else str(count)
+ lines.append(f"| {escape_table_cell(title)} | {crit_label} | {effort_str} | {occurrences_cell} |")
+
+ lines.append("")
+ lines.append("### Issue Details")
+ lines.append("")
+
+ for rule_id in rule_ids:
+ rule_obj = rules.get(rule_id, {})
+ title = rule_obj.get("title") or rule_id
+ incidents = rule_groups.get(rule_id, [])
+
+ locations = []
+ for inc in incidents:
+ file_path = (inc.get("location") or "").strip()
+ if not file_path:
+ continue
+ line_num = inc.get("line")
+ if line_num is not None:
+ try:
+ line_num = int(line_num)
+ locations.append(f"{file_path} (line {line_num})")
+ except (ValueError, TypeError):
+ locations.append(file_path)
+ else:
+ locations.append(file_path)
+
+ if not locations:
+ continue
+
+ anchor_id = make_anchor_id(title)
+ lines.append(f'')
+ lines.append(f"{escape_html(title)} — affected files ")
+ lines.append("")
+ for loc in locations:
+ lines.append(f"- `{loc}`")
+ lines.append("")
+ lines.append(" ")
+ lines.append("")
+
+ render_issue_section("Cloud Readiness Issues", cloud_rules)
+ render_issue_section("Upgrade Issues", upgrade_rules)
+
+ # Rearchitect findings
+ if rearchitect_findings:
+ lines.append("## Rearchitect Findings")
+ lines.append("")
+ lines.append("> **Note:** These findings were generated by AI and may contain inaccuracies or incomplete information. Please review carefully.")
+ lines.append("")
+ lines.append("| Finding | Old | New | Story Points | Files |")
+ lines.append("|---------|-----|-----|--------------|-------|")
+
+ for finding in rearchitect_findings:
+ name = finding.get("name", "")
+ old = finding.get("old", "")
+ new = finding.get("new", "")
+ story_points = finding.get("storyPoints", 5)
+ detected_in = finding.get("detectedIn", {}) or {}
+ config_files = detected_in.get("configFiles", []) or []
+ source_files = detected_in.get("sourceFiles", []) or []
+ all_files = config_files + source_files
+ anchor_id = make_anchor_id(name)
+ files_cell = f"[{len(all_files)}](#{anchor_id})" if all_files else "0"
+ lines.append(f"| {escape_table_cell(name)} | {escape_table_cell(old)} | {escape_table_cell(new)} | {story_points} | {files_cell} |")
+
+ lines.append("")
+ lines.append("### Rearchitect Finding Details")
+ lines.append("")
+
+ for finding in rearchitect_findings:
+ name = finding.get("name", "")
+ detected_in = finding.get("detectedIn", {}) or {}
+ config_files = detected_in.get("configFiles", []) or []
+ source_files = detected_in.get("sourceFiles", []) or []
+ all_files = config_files + source_files
+ if not all_files:
+ continue
+ anchor_id = make_anchor_id(name)
+ lines.append(f'')
+ lines.append(f"{escape_html(name)} — affected files ")
+ lines.append("")
+ for file in all_files:
+ lines.append(f"- `{file}`")
+ lines.append("")
+ explanation = finding.get("explanation", "")
+ if explanation and explanation.strip():
+ lines.append(f"**Explanation:** {explanation}")
+ lines.append("")
+ lines.append(" ")
+ lines.append("")
+
+ # Security Issues
+ if security_findings:
+ lines.append("## Security Issues")
+ lines.append("")
+ lines.append("> **Note:** These issues were generated by AI and may contain inaccuracies or incomplete information. Please review carefully.")
+ lines.append("")
+ lines.append("| Issue Name | Criticality | Story Points | Files |")
+ lines.append("|------------|-------------|--------------|-------|")
+
+ ordered_findings = sorted(
+ security_findings,
+ key=lambda f: (get_severity_rank((f.get("severity") or "").strip().lower()), -(f.get("storyPoint", 0) or 0))
+ )
+
+ for finding in ordered_findings:
+ severity = (finding.get("severity") or "").strip().lower()
+ crit_label = {"mandatory": "Mandatory", "potential": "Potential"}.get(severity, "Optional")
+ finding_id = finding.get("id", "")
+ title = finding.get("title", "")
+ display_name = f"{finding_id}: {title}" if title and title.strip() else finding_id
+ story_point = finding.get("storyPoint", 0) or 0
+ evidence = finding.get("evidence", {}) or {}
+ files = evidence.get("files", []) or []
+ anchor_id = make_anchor_id(display_name)
+ files_cell = f"[{len(files)}](#{anchor_id})" if files else "0"
+ lines.append(f"| {escape_table_cell(display_name)} | {crit_label} | {story_point} | {files_cell} |")
+
+ lines.append("")
+ lines.append("### Security Issue Details")
+ lines.append("")
+
+ for finding in ordered_findings:
+ evidence = finding.get("evidence", {}) or {}
+ files = evidence.get("files", []) or []
+ if not files:
+ continue
+ finding_id = finding.get("id", "")
+ title = finding.get("title", "")
+ display_name = f"{finding_id}: {title}" if title and title.strip() else finding_id
+ anchor_id = make_anchor_id(display_name)
+ lines.append(f'')
+ lines.append(f"{escape_html(display_name)} — affected files ")
+ lines.append("")
+ for file in files:
+ lines.append(f"- `{file}`")
+ lines.append("")
+ lines.append(" ")
+ lines.append("")
+
+ lines.append("---")
+ lines.append("")
+
+ # Codebase Insights
+ append_codebase_insights(lines, report_dir)
+
+ lines.append("[Share feedback](https://aka.ms/ghcp-appmod/feedback)")
+
+ return '\n'.join(lines) + '\n'
+
+
+def append_codebase_insights(lines: list, report_dir: str):
+ """Append Codebase Insights section with links to available fact files."""
+ fact_docs = [
+ ("architecture-diagram.md", "Architecture Diagram", "Understand the big picture: system layers and component relationships"),
+ ("dependency-map.md", "Dependency Map", "Know what the project depends on and where the risks are"),
+ ("api-service-contracts.md", "API & Service Contracts", "See how services communicate and what contracts they expose"),
+ ("data-architecture.md", "Data Architecture", "Explore data models, storage, and data flow patterns"),
+ ("configuration-inventory.md", "Configuration Inventory", "Review how the application is configured across environments"),
+ ("business-workflows.md", "Business Workflows", "Trace end-to-end business processes and domain logic"),
+ ]
+
+ facts_dir = os.path.join(report_dir, "facts")
+
+ lines.append("## Codebase Insights")
+ lines.append("")
+ lines.append("> **Note:** These documents are generated by AI and may contain inaccuracies or incomplete information. Please review carefully.")
+ lines.append("")
+
+ existing = []
+ if os.path.isdir(facts_dir):
+ for filename, title, desc in fact_docs:
+ if os.path.isfile(os.path.join(facts_dir, filename)):
+ existing.append((filename, title, desc))
+
+ if not existing:
+ lines.append("> **Codebase Insights aren't available yet.**")
+ lines.append(">")
+ lines.append("> These documents are generated when assessment runs with **Full analysis** coverage. Re-run the assessment and set `analysisCoverage: full` to enable them.")
+ lines.append("")
+ return
+
+ for i, (filename, title, desc) in enumerate(existing, 1):
+ lines.append(f"{i}. **[{title}](facts/{filename})** — {desc}")
+
+ lines.append("")
+
+
+def render_jsts_report(report_dir: str, ncu_path: str) -> str:
+ """Render report.md from js-assessment-report.md for JS/TS."""
+ with open(ncu_path, 'r', encoding='utf-8') as f:
+ ncu_output = f.read()
+
+ # Parse ncu output
+ dependencies = []
+ patch_count = 0
+ minor_count = 0
+ major_count = 0
+ zero_major_count = 0
+ package_manager = "npm"
+ current_category = ""
+ current_category_display = ""
+
+ for line in ncu_output.split('\n'):
+ trimmed = line.strip()
+ if trimmed.startswith("Using "):
+ package_manager = trimmed[len("Using "):].strip()
+ elif trimmed.startswith("Patch"):
+ current_category = "patch"
+ current_category_display = "Patch"
+ elif trimmed.startswith("Minor"):
+ current_category = "minor"
+ current_category_display = "Minor"
+ elif trimmed.startswith("Major Potentially breaking"):
+ current_category = "major"
+ current_category_display = "Major"
+ elif trimmed.startswith("Major version zero"):
+ current_category = "zero-major"
+ current_category_display = "Major (0.x)"
+ elif trimmed and '→' in trimmed:
+ arrow_idx = trimmed.index('→')
+ before_arrow = trimmed[:arrow_idx].strip()
+ after_arrow = trimmed[arrow_idx + 1:].strip()
+
+ parts = before_arrow.split()
+ name = ' '.join(parts[:-1]) if len(parts) >= 2 else before_arrow
+ current_version = parts[-1] if len(parts) >= 2 else ""
+
+ dependencies.append((name, current_version, after_arrow, current_category_display))
+
+ if current_category == "patch":
+ patch_count += 1
+ elif current_category == "minor":
+ minor_count += 1
+ elif current_category == "major":
+ major_count += 1
+ elif current_category == "zero-major":
+ zero_major_count += 1
+
+ total_count = patch_count + minor_count + major_count + zero_major_count
+
+ # Derive repo name from directory name
+ repo_name = os.path.basename(report_dir)
+
+ lines = []
+ lines.append(f"# {repo_name}")
+ lines.append("")
+ lines.append("JavaScript/TypeScript Dependency Assessment")
+ lines.append("")
+ lines.append("## Application Information")
+ lines.append("")
+ lines.append("| Property | Value |")
+ lines.append("|----------|-------|")
+ lines.append("| Language | JavaScript/TypeScript |")
+ lines.append(f"| Build tools | {escape_table_cell(package_manager)} |")
+ lines.append("")
+ lines.append("## Summary")
+ lines.append("")
+ lines.append("| Update Type | Count |")
+ lines.append("|-------------|-------|")
+ lines.append(f"| **Total Updates** | **{total_count}** |")
+ lines.append(f"| Patch | {patch_count} |")
+ lines.append(f"| Minor | {minor_count} |")
+ lines.append(f"| Major | {major_count} |")
+ if zero_major_count > 0:
+ lines.append(f"| Major (0.x) | {zero_major_count} |")
+ lines.append("")
+
+ if dependencies:
+ lines.append("## Dependency Updates")
+ lines.append("")
+ lines.append("| Package | Current | Target | Type |")
+ lines.append("|---------|---------|--------|------|")
+ for name, current, target, category in dependencies:
+ lines.append(f"| {escape_table_cell(name)} | {escape_table_cell(current)} | {escape_table_cell(target)} | {category} |")
+ lines.append("")
+
+ lines.append("## Recommendations")
+ lines.append("")
+ lines.append("| Update Type | Guidance |")
+ lines.append("|-------------|---------|")
+ lines.append("| Patch & Minor | Generally safe to apply. Consider updating these first. |")
+ lines.append("| Major | Review breaking changes in package release notes before updating. |")
+ lines.append("| Major (0.x) | Exercise caution — these packages follow unstable version semantics. |")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ append_codebase_insights(lines, report_dir)
+
+ lines.append("[Share feedback](https://aka.ms/ghcp-appmod/feedback)")
+
+ return '\n'.join(lines) + '\n'
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("Usage: python generate_report_md.py ", file=sys.stderr)
+ sys.exit(1)
+
+ report_dir = sys.argv[1]
+
+ if not os.path.isdir(report_dir):
+ print(f"Error: Directory not found: {report_dir}", file=sys.stderr)
+ sys.exit(1)
+
+ report_json_path = os.path.join(report_dir, "report.json")
+
+ if os.path.isfile(report_json_path):
+ # Java/.NET mode
+ markdown = render_appcat_report(report_dir, report_json_path)
+ else:
+ # JS/TS mode - look for js-assessment-report.md
+ ncu_path = os.path.join(report_dir, "js-assessment-report.md")
+ if not os.path.isfile(ncu_path):
+ # Check parent directory
+ parent = os.path.dirname(report_dir)
+ ncu_path = os.path.join(parent, "js-assessment-report.md")
+ if not os.path.isfile(ncu_path):
+ print("Error: Neither report.json nor js-assessment-report.md found.", file=sys.stderr)
+ sys.exit(1)
+ markdown = render_jsts_report(report_dir, ncu_path)
+
+ if not markdown:
+ print("Error: Failed to generate report.", file=sys.stderr)
+ sys.exit(1)
+
+ output_path = os.path.join(report_dir, "report.md")
+ with open(output_path, 'w', encoding='utf-8') as f:
+ f.write(markdown)
+
+ print(f"Generated: {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/modernize/assessment/reports/report-20260521055101/facts/api-service-contracts.md b/.github/modernize/assessment/reports/report-20260521055101/facts/api-service-contracts.md
new file mode 100644
index 000000000..d96e7e519
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/facts/api-service-contracts.md
@@ -0,0 +1,121 @@
+# API & Service Communication Contracts
+
+PhotoAlbum-Java exposes **5 HTTP endpoints** across three Spring MVC controllers; all communication is synchronous REST over HTTP with no API gateway, message broker, or external service integrations.
+
+## Service Catalog
+
+| Service | Port | Category | Purpose |
+|---------|------|----------|---------|
+| photoalbum-java-app | 8080 | Business | Spring Boot web application serving the photo gallery UI and REST upload API |
+| oracle-db | 1521 | Infrastructure | Oracle Database Free (third-party container) providing persistent BLOB and metadata storage |
+
+## API Endpoints Inventory
+
+| Service | Method | Path | Request Type | Response Type |
+|---------|--------|------|-------------|--------------|
+| HomeController | GET | `/` | — | HTML view (`index.html`) with `List` model |
+| HomeController | POST | `/upload` | `multipart/form-data` — `files` param (`List`) | JSON `200 OK` — `{success, uploadedPhotos[], failedUploads[]}` or `400 Bad Request` |
+| DetailController | GET | `/detail/{id}` | Path param `id` (String UUID) | HTML view (`detail.html`) with `Photo` model + nav IDs; redirects to `/` on not-found |
+| DetailController | POST | `/detail/{id}/delete` | Path param `id` (String UUID) | Redirect `302` to `/` with flash attributes |
+| PhotoFileController | GET | `/photo/{id}` | Path param `id` (String UUID) | Binary image response (`image/jpeg`, `image/png`, etc.) with `Cache-Control: no-store` headers; `404` if not found |
+
+## Management & Observability Endpoints
+
+| Service | Endpoint | Custom Metrics |
+|---------|----------|---------------|
+| photoalbum-java-app | None — Spring Boot Actuator is not on the classpath | None declared |
+
+No `/actuator/health`, `/actuator/metrics`, or `/actuator/prometheus` endpoints are available. There are no `@Timed` or custom Micrometer metric registrations in the codebase.
+
+## DTOs & Contracts
+
+**Service-level domain classes used in the API:**
+
+- **`Photo`** (JPA Entity / response model): Returned directly from service layer to controllers and rendered in Thymeleaf templates or serialized to JSON in the upload response. Not immutable — uses standard mutable POJO with getters/setters. Full field details are in `data-architecture.md`.
+- **`UploadResult`** (response DTO): Carries the outcome of a single-file upload operation: `success` (boolean), `fileName` (String), `photoId` (String UUID on success), and `errorMessage` (String on failure). Mutable POJO; provides a `failure(...)` static factory method. Consumed by `HomeController` to build the JSON upload response.
+
+No OpenAPI/Swagger specification, protobuf schemas, or GraphQL schemas are present. Jackson (via `spring-boot-starter-json`) handles JSON serialization for the upload response endpoint using default settings — no custom serializers or `ObjectMapper` configuration is declared.
+
+## Communication Patterns
+
+**Synchronous only.** All client-to-application communication is HTTP/1.1 REST. The application makes no outbound HTTP calls to other services; all downstream communication is via JDBC to Oracle.
+
+**Resilience:** No circuit breaker, retry policy, bulkhead, or timeout configuration is declared (no Resilience4j, Spring Retry, or equivalent). Failed database operations propagate as unchecked `RuntimeException` to the controller, which returns a `500` response or redirects to the home page.
+
+**Service discovery:** Not applicable — single-service deployment. The Oracle JDBC URL is hardcoded in `application.properties` (default profile) and overridden via environment variable `SPRING_DATASOURCE_URL` in the Docker profile.
+
+**Startup dependency chain:** The `docker-compose.yml` configures `photoalbum-java-app` to wait for `oracle-db` to pass its health check (`condition: service_healthy`) before starting. No application-level readiness probe is registered. For full Docker configuration details see `configuration-inventory.md`.
+
+**Security posture:** No authentication, authorization, or TLS is configured. Spring Security is not on the classpath. All five endpoints — including photo deletion (`POST /detail/{id}/delete`) and file upload (`POST /upload`) — are publicly accessible with no authorization checks. There is no CSRF protection and no HTTPS configuration.
+
+## Service Technology Matrix
+
+| Service | Web Framework | Data Access | Discovery | Gateway | Actuator/Health | Cache | Metrics |
+|---------|--------------|-------------|-----------|---------|----------------|-------|---------|
+| photoalbum-java-app | Spring MVC (servlet) | Spring Data JPA + Hibernate 5.6 | None | None | None | None | None |
+| oracle-db | N/A (third-party) | N/A | N/A | N/A | Docker healthcheck only | N/A | N/A |
+
+## Service Communication Sequence
+
+```mermaid
+sequenceDiagram
+ participant Client as "Browser / HTTP Client"
+ participant HomeCtrl as "HomeController"
+ participant DetailCtrl as "DetailController"
+ participant FileCtrl as "PhotoFileController"
+ participant PhotoSvc as "PhotoServiceImpl"
+ participant Repo as "PhotoRepository"
+ participant DB as "Oracle Database"
+
+ Note over Client,DB: Gallery page load
+ Client->>HomeCtrl: GET /
+ HomeCtrl->>PhotoSvc: getAllPhotos()
+ PhotoSvc->>Repo: findAllOrderByUploadedAtDesc()
+ Repo->>DB: SELECT ... FROM PHOTOS ORDER BY UPLOADED_AT DESC
+ DB-->>Repo: List of Photo rows
+ Repo-->>PhotoSvc: List
+ PhotoSvc-->>HomeCtrl: List
+ HomeCtrl-->>Client: 200 HTML (index.html, photo grid)
+
+ Note over Client,DB: Photo upload
+ Client->>HomeCtrl: POST /upload (multipart files)
+ loop For each file
+ HomeCtrl->>PhotoSvc: uploadPhoto(MultipartFile)
+ PhotoSvc->>PhotoSvc: Validate MIME type and size
+ alt Validation fails
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, error)
+ else Validation passes
+ PhotoSvc->>Repo: save(Photo with BLOB data)
+ Repo->>DB: INSERT INTO PHOTOS (... photo_data BLOB ...)
+ DB-->>Repo: saved Photo
+ Repo-->>PhotoSvc: Photo
+ PhotoSvc-->>HomeCtrl: UploadResult(success=true, photoId)
+ end
+ end
+ HomeCtrl-->>Client: 200 JSON {success, uploadedPhotos[], failedUploads[]}
+
+ Note over Client,DB: Serve photo binary
+ Client->>FileCtrl: GET /photo/{id}
+ FileCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>Repo: findById(id)
+ Repo->>DB: SELECT ... FROM PHOTOS WHERE ID = ?
+ DB-->>Repo: Photo row with BLOB
+ Repo-->>PhotoSvc: Optional
+ alt Photo found
+ PhotoSvc-->>FileCtrl: Optional.of(Photo)
+ FileCtrl-->>Client: 200 image/jpeg (binary BLOB data, no-cache headers)
+ else Not found
+ PhotoSvc-->>FileCtrl: Optional.empty()
+ FileCtrl-->>Client: 404 Not Found
+ end
+
+ Note over Client,DB: Delete photo
+ Client->>DetailCtrl: POST /detail/{id}/delete
+ DetailCtrl->>PhotoSvc: deletePhoto(id)
+ PhotoSvc->>Repo: delete(Photo)
+ Repo->>DB: DELETE FROM PHOTOS WHERE ID = ?
+ DB-->>Repo: OK
+ Repo-->>PhotoSvc: void
+ PhotoSvc-->>DetailCtrl: true
+ DetailCtrl-->>Client: 302 Redirect to /
+```
diff --git a/.github/modernize/assessment/reports/report-20260521055101/facts/architecture-diagram.md b/.github/modernize/assessment/reports/report-20260521055101/facts/architecture-diagram.md
new file mode 100644
index 000000000..e108a793e
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/facts/architecture-diagram.md
@@ -0,0 +1,96 @@
+# Architecture Diagram
+
+PhotoAlbum-Java is a Spring Boot 2.7 web application that allows users to upload, browse, and manage photos, storing image data as BLOBs in an Oracle database.
+
+## Application Architecture
+
+```mermaid
+flowchart TD
+ subgraph Client["Client Layer"]
+ Browser["Web Browser"]
+ end
+ subgraph App["Application Layer - Spring Boot 2.7 / Java 8"]
+ Web["Spring MVC Controllers\n(HomeController, DetailController,\nPhotoFileController)"]
+ Template["Thymeleaf Templates\n(index, detail, layout)"]
+ Service["Business Services\n(PhotoServiceImpl)"]
+ Validation["Bean Validation\n(Spring Validator)"]
+ end
+ subgraph Data["Data Layer"]
+ JPA["Spring Data JPA\n(PhotoRepository)"]
+ DB[("Oracle Database\n(FREEPDB1 / BLOB storage)")]
+ end
+
+ Browser -->|"HTTP GET / POST"| Web
+ Web -->|"renders"| Template
+ Web -->|"delegates"| Service
+ Service -->|"validates"| Validation
+ Service -->|"CRUD + native queries"| JPA
+ JPA -->|"JDBC / ojdbc8"| DB
+```
+
+### Technology Stack Summary
+
+| Layer | Technology | Version | Purpose |
+|-------|-----------|---------|---------|
+| Presentation | Spring MVC + Thymeleaf | 2.7.18 / 3.x | Server-side rendering and REST endpoints |
+| Business Logic | Spring Boot Service | 2.7.18 | Photo upload, retrieval, deletion, navigation |
+| Data Access | Spring Data JPA / Hibernate | 2.7.18 | ORM and query abstraction |
+| Database | Oracle Database (FREEPDB1) | Oracle XE / Free | Persistent storage for photo metadata and BLOBs |
+| Runtime | Java | 8 | Application runtime |
+| Build | Apache Maven | 3.x | Dependency management and build |
+| Testing | JUnit 5 + H2 | Spring Boot 2.7.18 | Unit and integration tests |
+
+### Data Storage & External Services
+
+Photos and their metadata (filename, MIME type, dimensions, upload timestamp) are stored entirely within an Oracle Database instance. The binary image content is persisted as a `BLOB` (`byte[]` mapped via `@Lob`) in the `photos` table, eliminating the need for a file system or external object store. The application connects to Oracle via the `ojdbc8` JDBC driver using a fixed data source configured in `application.properties`. No external caches, message brokers, or third-party APIs are used; all data flows are internal between the Spring Boot process and the Oracle instance.
+
+### Key Architectural Decisions
+
+- **BLOB storage in Oracle**: Images are stored directly in the database as byte arrays rather than on disk or in cloud object storage, simplifying deployment but coupling the app tightly to Oracle.
+- **Native SQL queries for Oracle-specific features**: The `PhotoRepository` uses Oracle-specific functions (`ROWNUM`, `TO_CHAR`, `NVL`, analytical `RANK() OVER`) in native queries to implement pagination, filtering, and ranking.
+- **Constructor injection + `@Transactional` service**: `PhotoServiceImpl` receives all dependencies via constructor and is annotated `@Transactional`, following standard Spring best practices for testability and transaction management.
+
+## Component Relationships
+
+```mermaid
+flowchart LR
+ subgraph Presentation["Presentation Layer"]
+ HomeCtrl["HomeController"]
+ DetailCtrl["DetailController"]
+ PhotoFileCtrl["PhotoFileController"]
+ end
+ subgraph Business["Business Logic Layer"]
+ PhotoSvc["PhotoService (interface)"]
+ PhotoSvcImpl["PhotoServiceImpl"]
+ end
+ subgraph DataAccess["Data Access Layer"]
+ PhotoRepo["PhotoRepository\n(JpaRepository)"]
+ end
+ subgraph Model["Domain Model"]
+ PhotoEntity["Photo (JPA Entity)"]
+ UploadResult["UploadResult (DTO)"]
+ end
+
+ HomeCtrl -->|"delegates upload/list"| PhotoSvc
+ DetailCtrl -->|"delegates view/delete/nav"| PhotoSvc
+ PhotoFileCtrl -->|"delegates file serve"| PhotoSvc
+ PhotoSvc -.->|"implemented by"| PhotoSvcImpl
+ PhotoSvcImpl -->|"queries/saves"| PhotoRepo
+ PhotoRepo -->|"maps to/from"| PhotoEntity
+ PhotoSvcImpl -->|"produces"| UploadResult
+ HomeCtrl -->|"returns"| UploadResult
+```
+
+### Component Inventory
+
+| Component | Layer | Type | Responsibility |
+|-----------|-------|------|---------------|
+| HomeController | Presentation | Spring MVC Controller | Handles GET `/` (gallery list) and POST `/upload` (multi-file upload); returns HTML view or JSON |
+| DetailController | Presentation | Spring MVC Controller | Handles GET `/detail/{id}` (single photo view) and POST `/detail/{id}/delete` (photo deletion) |
+| PhotoFileController | Presentation | Spring MVC Controller | Handles GET `/photo/{id}` to stream BLOB photo data with appropriate `Content-Type` headers |
+| PhotoService | Business Logic | Service Interface | Defines contract for all photo operations (list, get, upload, delete, navigation) |
+| PhotoServiceImpl | Business Logic | Service Implementation | Validates files (MIME type, size), reads bytes, extracts dimensions via `ImageIO`, persists via repository |
+| PhotoRepository | Data Access | Spring Data JPA Repository | Extends `JpaRepository`; provides CRUD plus Oracle-specific native queries for ordering, pagination, and statistics |
+| Photo | Domain Model | JPA Entity | Maps to `photos` table; holds metadata and `@Lob` binary image data |
+| UploadResult | Domain Model | DTO | Carries upload outcome (success flag, photo ID or error message) between service and controller |
+| MathUtil | Utility | Utility Class | General-purpose math helper utilities |
diff --git a/.github/modernize/assessment/reports/report-20260521055101/facts/assessment-overview.md b/.github/modernize/assessment/reports/report-20260521055101/facts/assessment-overview.md
new file mode 100644
index 000000000..a5ac4050f
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/facts/assessment-overview.md
@@ -0,0 +1,14 @@
+# Assessment Overview
+
+This directory contains supplementary analysis documents generated for the PhotoAlbum-Java application as part of the Azure migration readiness assessment (report ID: `20260521055101`). Each document covers a distinct aspect of the application to support modernization planning.
+
+## Supplementary Documents
+
+| Document | Description |
+|----------|-------------|
+| [architecture-diagram.md](architecture-diagram.md) | Two-layer architecture visualization: high-level application architecture (layers, data storage, external services) and a detailed component relationship diagram showing how Spring controllers, services, repositories, and domain models interact. |
+| [dependency-map.md](dependency-map.md) | Visual map of all declared external dependencies grouped by functional category (web frameworks, database/ORM, validation, utilities), including version and compatibility risks, notable observations, and test dependencies. |
+| [api-service-contracts.md](api-service-contracts.md) | Catalog of all HTTP API endpoints, service definitions, DTO/contract types, communication patterns (sync/async), security posture, and a Mermaid sequence diagram of the primary request flows. |
+| [data-architecture.md](data-architecture.md) | Data layer documentation covering database configuration per profile, the `Photo` JPA entity model (ER diagram), key repository methods including Oracle-specific native queries, caching strategy, and data classification/sensitivity analysis. |
+| [configuration-inventory.md](configuration-inventory.md) | Comprehensive inventory of all configuration sources, runtime profiles (default, docker, test), property keys and values, startup dependency chain, secrets/sensitive configuration, and framework/runtime version matrix. |
+| [business-workflows.md](business-workflows.md) | End-to-end documentation of the core business workflows (photo upload, gallery browse, detail view, binary serving, deletion), domain entities, business rules and validation logic, and a Mermaid sequence diagram of the primary upload and browse flows. |
diff --git a/.github/modernize/assessment/reports/report-20260521055101/facts/business-workflows.md b/.github/modernize/assessment/reports/report-20260521055101/facts/business-workflows.md
new file mode 100644
index 000000000..b794120aa
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/facts/business-workflows.md
@@ -0,0 +1,209 @@
+# Core Business Workflows
+
+PhotoAlbum-Java is a personal photo gallery application that lets users upload, browse, view, and delete images stored in a central database.
+
+## Domain Entities
+
+| Entity | Service / Bounded Context | Description | Key Relationships |
+|--------|--------------------------|-------------|------------------|
+| Photo | Photo Management (single bounded context) | Represents an uploaded image with its binary content and descriptive metadata (name, size, MIME type, dimensions, upload timestamp) | Self-referential temporal ordering: photos are navigated sequentially by `uploadedAt` timestamp (previous / next) |
+
+## Service-to-Domain Mapping
+
+| Service | Domain Context | Owned Entities | External Dependencies |
+|---------|---------------|---------------|----------------------|
+| photoalbum-java-app | Photo Management | `Photo` | Oracle Database (persistence of metadata + BLOB image data) |
+
+This is a single-service, single-context application. There are no inter-service dependencies, event buses, or remote API calls to external services.
+
+## Primary Workflows
+
+### Workflow 1: Photo Upload
+
+A user selects one or more image files in the browser gallery and submits them. The application validates each file, extracts image dimensions, stores the binary content in Oracle, and returns a structured JSON response indicating which uploads succeeded and which failed.
+
+**Steps:**
+1. User submits a multipart POST request with one or more image files.
+2. Controller iterates over each `MultipartFile` and calls `PhotoService.uploadPhoto()`.
+3. Service validates the file's MIME type against the configured allow-list (`image/jpeg`, `image/png`, `image/gif`, `image/webp`).
+4. Service validates the file size does not exceed the configured maximum (10 MB).
+5. Service validates that the file is non-empty (size > 0).
+6. Service reads all bytes from the multipart stream and attempts to extract pixel dimensions using `ImageIO.read()`.
+7. Service constructs a `Photo` entity with a UUID primary key, binary data, metadata, and current timestamp; persists it to Oracle.
+8. Service returns an `UploadResult(success=true, photoId=)` to the controller.
+9. Controller aggregates all individual results into a JSON response: `{success, uploadedPhotos[], failedUploads[]}`.
+
+**Business rules involved:** File type validation, file size limit, empty file rejection, image dimension extraction (best-effort; non-critical failure is tolerated).
+
+---
+
+### Workflow 2: Gallery Browse
+
+A user loads the home page to see all uploaded photos in reverse-chronological order.
+
+**Steps:**
+1. User sends a GET request to `/`.
+2. Controller calls `PhotoService.getAllPhotos()`.
+3. Service queries Oracle for all photos ordered by `uploadedAt DESC` (native SQL).
+4. Controller passes the photo list to the Thymeleaf `index.html` template.
+5. Template renders a thumbnail grid; each thumbnail references `/photo/{id}` for the image binary.
+
+---
+
+### Workflow 3: View Photo Detail with Navigation
+
+A user clicks a photo to view it full-size with previous/next navigation.
+
+**Steps:**
+1. User sends a GET request to `/detail/{id}`.
+2. Controller calls `PhotoService.getPhotoById(id)`; redirects to `/` if not found.
+3. Controller calls `PhotoService.getPreviousPhoto(photo)` — queries Oracle for the most recent photo uploaded before the current one.
+4. Controller calls `PhotoService.getNextPhoto(photo)` — queries Oracle for the oldest photo uploaded after the current one.
+5. Controller passes `photo`, `previousPhotoId`, and `nextPhotoId` to the `detail.html` template.
+6. Template renders the full-size image (referencing `/photo/{id}`) and navigation arrows.
+
+---
+
+### Workflow 4: Serve Photo Binary
+
+The browser fetches the actual image bytes for rendering thumbnails and full-size views.
+
+**Steps:**
+1. Browser sends a GET request to `/photo/{id}` (triggered by an ` ` tag).
+2. Controller calls `PhotoService.getPhotoById(id)`; returns `404` if not found.
+3. Controller reads the `photoData` byte array from the `Photo` entity.
+4. Controller returns the binary BLOB with the stored MIME type and `Cache-Control: no-cache, no-store` headers.
+
+---
+
+### Workflow 5: Delete Photo
+
+A user deletes a photo from the detail page.
+
+**Steps:**
+1. User submits a POST request to `/detail/{id}/delete`.
+2. Controller calls `PhotoService.deletePhoto(id)`.
+3. Service looks up the photo by ID; returns `false` (not found) if absent.
+4. Service calls `PhotoRepository.delete(photo)`, removing the row (and BLOB) from Oracle.
+5. Controller sets a flash attribute (`successMessage` or `errorMessage`) and redirects to `/`.
+
+## Cross-Service Data Flows
+
+PhotoAlbum-Java is a monolithic single-service application. All data originates from and returns to a single Oracle database schema. There are no inter-service REST calls, event-driven integrations, or data aggregation across multiple upstream services.
+
+The only data composition that occurs is within the detail-view workflow, where the service makes three sequential queries (fetch current photo, fetch previous photo, fetch next photo) and the controller assembles the navigation context before rendering the template. This is intra-service composition, not cross-service.
+
+No circuit-breaker fallback paths apply — if Oracle is unavailable, all workflows fail with a runtime exception; there is no degraded-mode behavior implemented.
+
+## Business Workflow Sequence
+
+```mermaid
+sequenceDiagram
+ participant User as "User (Browser)"
+ participant HomeCtrl as "HomeController"
+ participant DetailCtrl as "DetailController"
+ participant FileCtrl as "PhotoFileController"
+ participant PhotoSvc as "PhotoService"
+ participant DB as "Oracle Database"
+
+ Note over User,DB: Workflow 1 - Upload Photo
+ User->>HomeCtrl: POST /upload (image files)
+ loop For each uploaded file
+ HomeCtrl->>PhotoSvc: uploadPhoto(file)
+ PhotoSvc->>PhotoSvc: Validate MIME type
+ alt Invalid MIME type
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, "type not supported")
+ else MIME type OK
+ PhotoSvc->>PhotoSvc: Validate file size <= 10MB
+ alt File too large
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, "exceeds size limit")
+ else Size OK
+ PhotoSvc->>PhotoSvc: Read bytes, extract dimensions (ImageIO)
+ PhotoSvc->>DB: INSERT Photo (UUID, BLOB, metadata, timestamp)
+ DB-->>PhotoSvc: Saved Photo
+ PhotoSvc-->>HomeCtrl: UploadResult(success=true, photoId)
+ end
+ end
+ end
+ HomeCtrl-->>User: JSON {success, uploadedPhotos[], failedUploads[]}
+
+ Note over User,DB: Workflow 2 - Browse Gallery
+ User->>HomeCtrl: GET /
+ HomeCtrl->>PhotoSvc: getAllPhotos()
+ PhotoSvc->>DB: SELECT all photos ORDER BY uploaded_at DESC
+ DB-->>PhotoSvc: List
+ PhotoSvc-->>HomeCtrl: List
+ HomeCtrl-->>User: HTML gallery page
+
+ Note over User,DB: Workflow 3 - View Photo Detail
+ User->>DetailCtrl: GET /detail/{id}
+ DetailCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>DB: SELECT photo WHERE id = ?
+ DB-->>PhotoSvc: Photo
+ PhotoSvc-->>DetailCtrl: Optional
+ alt Photo not found
+ DetailCtrl-->>User: Redirect to /
+ else Photo found
+ DetailCtrl->>PhotoSvc: getPreviousPhoto(photo)
+ PhotoSvc->>DB: SELECT older photo (UPLOADED_AT < current)
+ DB-->>PhotoSvc: Optional previous Photo
+ DetailCtrl->>PhotoSvc: getNextPhoto(photo)
+ PhotoSvc->>DB: SELECT newer photo (UPLOADED_AT > current)
+ DB-->>PhotoSvc: Optional next Photo
+ DetailCtrl-->>User: HTML detail page with nav arrows
+ User->>FileCtrl: GET /photo/{id} (image src)
+ FileCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>DB: SELECT photo_data BLOB WHERE id = ?
+ DB-->>PhotoSvc: Photo with BLOB
+ PhotoSvc-->>FileCtrl: Photo
+ FileCtrl-->>User: Binary image (MIME type, no-cache headers)
+ end
+```
+
+## Business Rules & Decision Logic
+
+### Validation Rules
+
+| Rule | Applies To | Behavior on Violation |
+|------|-----------|----------------------|
+| Allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp` | Upload | Returns `UploadResult(success=false)` with "File type not supported" message; upload for that file is skipped |
+| Max file size: 10,485,760 bytes (10 MB) | Upload | Returns `UploadResult(success=false)` with "File size exceeds XMB limit" message |
+| Non-empty file (size > 0) | Upload | Returns `UploadResult(success=false)` with "File is empty" message |
+| Non-null, non-blank photo ID | Detail view, file serve, delete | Redirects to `/` (detail/delete) or returns `404` (file serve) |
+| Maximum files per upload: 10 | Upload (multipart config) | Enforced at HTTP layer by Spring multipart `max-request-size=50MB`; no explicit business-layer enforcement of the `max-files-per-upload=10` property |
+
+### Decision Logic
+
+- **Batch upload result aggregation**: A POST `/upload` request with multiple files is processed file-by-file. Individual failures do not abort the entire batch; `success` in the response is `true` if at least one file uploaded successfully.
+- **Image dimensions (best-effort)**: `ImageIO.read()` is attempted to extract pixel width/height. If it returns `null` or throws, the photo is still persisted without dimensions — dimension extraction failure is non-fatal.
+- **Navigation boundary**: `getPreviousPhoto` and `getNextPhoto` return `Optional.empty()` when no older/newer photo exists; the template hides the corresponding navigation arrow.
+
+### State Transitions
+
+`Photo` has a simple two-state lifecycle:
+
+```
+[Uploaded / Persisted] → (user deletes) → [Deleted / Removed]
+```
+
+There are no intermediate states (draft, pending, approved). Once saved, a photo is immediately visible in the gallery.
+
+### Transaction Boundaries
+
+- `PhotoServiceImpl` is annotated `@Transactional` at the class level — all public methods participate in a transaction by default.
+- Read operations (`getAllPhotos`, `getPhotoById`, `getPreviousPhoto`, `getNextPhoto`) are overridden with `@Transactional(readOnly = true)` to allow Hibernate optimizations.
+- Each upload call (`uploadPhoto`) runs in its own transaction — a failure for one file does not roll back uploads that already completed.
+
+### Error Handling
+
+- All service-layer exceptions are caught in the controller and either result in a redirect to `/` (with flash error message) or a JSON `failedUploads` entry. No custom business exception types are defined.
+- Unexpected exceptions in `getAllPhotos` cause the gallery to render with an empty list rather than a 500 error page.
+- `deletePhoto` throws `RuntimeException` on unexpected errors, which surfaces as a 500 if uncaught by the controller.
+
+### Authorization
+
+No authentication or authorization is implemented. All workflows are available to any anonymous HTTP client. See `api-service-contracts.md` for security posture details.
+
+### Audit / Logging
+
+Business operations are logged at DEBUG level (INFO in Docker) using SLF4J. Notable log events: successful upload with photo ID, upload rejection with reason, deletion confirmation. There is no formal audit trail, change-event log, or external audit sink.
diff --git a/.github/modernize/assessment/reports/report-20260521055101/facts/configuration-inventory.md b/.github/modernize/assessment/reports/report-20260521055101/facts/configuration-inventory.md
new file mode 100644
index 000000000..e1e23d2d2
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/facts/configuration-inventory.md
@@ -0,0 +1,157 @@
+# Configuration & Externalized Settings Inventory
+
+PhotoAlbum-Java uses three Spring Boot property files (default, docker, test profiles) as its sole configuration source, with secrets supplied via plain-text properties and Docker environment variable overrides — no external config server or secret store is employed.
+
+## Configuration Sources
+
+| Source | Type | Path / Location | Notes |
+|--------|------|----------------|-------|
+| `application.properties` | Spring Boot default profile | `src/main/resources/application.properties` | Active in local development; connects to Oracle at `oracle-db:1521/FREEPDB1` |
+| `application-docker.properties` | Spring Boot `docker` profile | `src/main/resources/application-docker.properties` | Activated via `SPRING_PROFILES_ACTIVE=docker` in Docker Compose; overrides JDBC URL to `oracle-db:1521:XE` |
+| `application-test.properties` | Spring Boot `test` profile | `src/test/resources/application-test.properties` | Active during `mvn test`; substitutes Oracle with H2 in-memory DB |
+| `docker-compose.yml` | Docker Compose environment | `docker-compose.yml` (root) | Injects `SPRING_PROFILES_ACTIVE`, `SPRING_DATASOURCE_URL/USERNAME/PASSWORD` into the app container; sets Oracle init-db environment variables |
+| `oracle-init/01-create-user.sql` | Oracle init script | `oracle-init/01-create-user.sql` | Executed automatically by the Oracle Free container on first start; creates `photoalbum` user with DBA privileges |
+| `oracle-init/02-verify-user.sql` | Oracle init script | `oracle-init/02-verify-user.sql` | Post-creation verification query |
+| `Dockerfile` | Container build config | `Dockerfile` (root) | Multi-stage build; sets default `JAVA_OPTS=-Xmx512m -Xms256m` |
+
+No Spring Cloud Config server, Kubernetes ConfigMaps, HashiCorp Vault, Azure Key Vault, or AWS Secrets Manager references are present. No `bootstrap.properties` or `bootstrap.yml` files exist.
+
+## Build Profiles
+
+| Profile | Activation | Purpose | Key Dependencies / Plugins |
+|---------|-----------|---------|---------------------------|
+| (default) | Automatic — no `-P` flag required | Standard local build with all dependencies; runs tests against H2 | `spring-boot-maven-plugin` for executable JAR; `spring-boot-starter-test` + H2 in test scope |
+| Docker multi-stage build | Triggered by `docker build` / `docker-compose up --build` | Compiles and packages in `maven:3.9.6-eclipse-temurin-8`; copies JAR to `eclipse-temurin:8-jre` runtime image | `mvn clean package -DskipTests` (skips tests inside Docker build); no additional Maven profiles declared in `pom.xml` |
+
+No explicit Maven `` blocks are declared in `pom.xml`. The only build variation is between a local Maven build (runs tests) and the Docker-contained build (skips tests).
+
+## Runtime Profiles
+
+| Profile | Activation Method | Config Files | Key Overrides vs Default |
+|---------|-----------------|-------------|-------------------------|
+| (default) | None — active when no profile is set | `application.properties` | Baseline — Oracle at `oracle-db:1521/FREEPDB1`, log level DEBUG for app code |
+| `docker` | `SPRING_PROFILES_ACTIVE=docker` (set in `docker-compose.yml`) | `application.properties` + `application-docker.properties` | JDBC URL changed to `oracle-db:1521:XE`; app log level reduced to INFO; Hibernate SQL log to DEBUG |
+| `test` | Applied automatically by `spring-boot-starter-test` via `application-test.properties` in test classpath | `application-test.properties` | Oracle replaced with `jdbc:h2:mem:testdb`; `ddl-auto=create-drop`; SQL logging disabled; upload path set to `target/test-uploads` |
+
+Multiple active profiles are not combined in any declared configuration. The `docker` profile fully composes with the base `application.properties` (Spring Boot merges them).
+
+## Properties Inventory
+
+### photoalbum-java-app — Server & Encoding
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `server.port` | `8080` | `8080` | — | `application.properties` |
+| `server.servlet.encoding.charset` | `UTF-8` | `UTF-8` | — | `application.properties` |
+| `server.servlet.encoding.enabled` | `true` | `true` | — | `application.properties` |
+| `server.servlet.encoding.force` | `true` | `true` | — | `application.properties` |
+
+### photoalbum-java-app — DataSource
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `spring.datasource.url` | `jdbc:oracle:thin:@oracle-db:1521/FREEPDB1` | `jdbc:oracle:thin:@oracle-db:1521:XE` | `jdbc:h2:mem:testdb` | Profile files |
+| `spring.datasource.username` | `photoalbum` | `photoalbum` | `sa` | Profile files |
+| `spring.datasource.password` | `photoalbum` [SENSITIVE] | `photoalbum` [SENSITIVE] | _(empty)_ | Profile files |
+| `spring.datasource.driver-class-name` | `oracle.jdbc.OracleDriver` | `oracle.jdbc.OracleDriver` | `org.h2.Driver` | Profile files |
+
+### photoalbum-java-app — JPA / Hibernate
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `spring.jpa.database-platform` | `org.hibernate.dialect.OracleDialect` | `org.hibernate.dialect.OracleDialect` | `org.hibernate.dialect.H2Dialect` | Profile files |
+| `spring.jpa.hibernate.ddl-auto` | `create` | `create` | `create-drop` | Profile files |
+| `spring.jpa.show-sql` | `true` | `true` | `false` | Profile files |
+| `spring.jpa.properties.hibernate.format_sql` | `true` | `true` | — | Profile files |
+
+### photoalbum-java-app — File Upload
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `spring.servlet.multipart.max-file-size` | `10MB` | `10MB` | — | `application.properties` |
+| `spring.servlet.multipart.max-request-size` | `50MB` | `50MB` | — | `application.properties` |
+| `app.file-upload.max-file-size-bytes` | `10485760` | `10485760` | `10485760` | Profile files |
+| `app.file-upload.allowed-mime-types` | `image/jpeg,image/png,image/gif,image/webp` | same | same | Profile files |
+| `app.file-upload.max-files-per-upload` | `10` | `10` | `10` | Profile files |
+| `app.file-upload.upload-path` | — | — | `target/test-uploads` | `application-test.properties` |
+
+### photoalbum-java-app — Logging
+
+| Property Key | Default | docker profile | test profile | Source |
+|-------------|---------|---------------|-------------|--------|
+| `logging.level.com.photoalbum` | `DEBUG` | `INFO` | `DEBUG` | Profile files |
+| `logging.level.org.springframework.web` | `DEBUG` | `WARN` | — | Profile files |
+| `logging.level.org.hibernate.SQL` | — | `DEBUG` | — | `application-docker.properties` |
+
+## Startup Parameters & Resource Requirements
+
+| Service | JVM / Runtime Options | Memory | CPU | Instance Count |
+|---------|----------------------|--------|-----|---------------|
+| photoalbum-java-app (Docker) | `JAVA_OPTS=-Xmx512m -Xms256m` (set in Dockerfile `ENV`) | No `mem_limit` set in Compose | Not specified | 1 (no scaling config) |
+| photoalbum-java-app (local Maven) | JVM default (no explicit heap flags) | Host JVM defaults | Not specified | 1 |
+| oracle-db | Oracle Free container defaults | No `mem_limit` set in Compose | Not specified | 1 |
+
+The `JAVA_OPTS` environment variable is read by the `ENTRYPOINT` script (`sh -c "java $JAVA_OPTS -jar app.jar"`). It can be overridden at `docker run` time or via `docker-compose.yml` `environment` section. No `-Dspring.profiles.active` JVM system property is used; profile activation is exclusively via `SPRING_PROFILES_ACTIVE` environment variable.
+
+## Startup Dependency Chain
+
+```
+oracle-db → (Docker healthcheck: healthcheck.sh, interval 30s, timeout 10s, retries 15, start_period 180s)
+ ↓
+photoalbum-java-app → depends_on: oracle-db (condition: service_healthy)
+ restart policy: on-failure
+```
+
+1. **`oracle-db`** starts first. The Docker Compose health check runs `healthcheck.sh` inside the Oracle container every 30 seconds with a 180-second start period and up to 15 retries (~7.5 minutes total patience).
+2. **`photoalbum-java-app`** only starts after `oracle-db` reports healthy. No application-level readiness probe (Spring Boot Actuator is not on the classpath). If Oracle is unavailable after the container starts, the Spring application context will fail to initialize (Hibernate `ddl-auto=create` requires a live connection at startup) and Docker Compose will restart the container per `restart: on-failure`.
+
+There is no config-server, discovery-server, or API gateway in the startup chain.
+
+## Secrets & Sensitive Configuration
+
+| Secret Reference | Type | Profile | Storage |
+|----------------|------|---------|---------|
+| `spring.datasource.password` | Oracle DB password | default, docker | Plain-text in `application.properties` / `application-docker.properties` — value: [MASKED] |
+| `SPRING_DATASOURCE_PASSWORD` | Oracle DB password (env var override) | docker (Compose) | Plain-text in `docker-compose.yml` — value: [MASKED] |
+| `ORACLE_PASSWORD` | Oracle root/sys password | docker (Compose, oracle-db service) | Plain-text in `docker-compose.yml` — value: [MASKED] |
+| `APP_USER_PASSWORD` | Oracle app-user password | docker (Compose, oracle-db service) | Plain-text in `docker-compose.yml` — value: [MASKED] |
+
+### Secrets Provisioning Workflow
+
+All secrets are stored as plain-text values in source-controlled configuration files (`application.properties`, `docker-compose.yml`). There is no secrets management system in use:
+
+- **No external secret store**: No HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, or Kubernetes Secrets are referenced.
+- **No encryption**: No Jasypt, DPAPI, or sealed-secret encryption of property values.
+- **Credential flow**: Oracle credentials are hard-coded in `application.properties` (default profile) and additionally injected as `SPRING_DATASOURCE_*` environment variables in `docker-compose.yml`. The values in both locations are identical plain-text strings.
+- **Risk**: Committing database passwords to source control is a critical security issue. Any developer or CI system with repository read access can obtain the database credentials.
+
+**Recommended remediation**: Externalize secrets to a vault (e.g., Azure Key Vault with managed identity, or GitHub Actions secrets injected at deploy time) and remove credential values from all checked-in files.
+
+## Feature Flags
+
+No feature flag framework is present. No `@ConditionalOnProperty`, `@ConditionalOnExpression`, LaunchDarkly, Unleash, or custom toggle mechanism is used. The only conditional behaviour is the standard Spring Boot profile activation that selects the appropriate `application-{profile}.properties` file.
+
+| Flag Name | Default | Controlled By |
+|-----------|---------|--------------|
+| None detected | — | — |
+
+## Framework & Runtime Versions
+
+| Component | Version | Source |
+|-----------|---------|--------|
+| Java (compile target) | 8 (1.8) | `pom.xml` — `maven.compiler.source/target` |
+| Java (Docker build) | 8 (`eclipse-temurin:8`) | `Dockerfile` — `FROM maven:3.9.6-eclipse-temurin-8` / `FROM eclipse-temurin:8-jre` |
+| Spring Boot | 2.7.18 | `pom.xml` — parent BOM |
+| Spring MVC | 5.3.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-web` |
+| Hibernate | 5.6.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-data-jpa` |
+| Thymeleaf | 3.0.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-thymeleaf` |
+| Hibernate Validator | 6.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-validation` |
+| Jackson | 2.14.x (managed by Spring Boot 2.7.18 BOM) | Transitive via `spring-boot-starter-json` |
+| Oracle JDBC (ojdbc8) | Managed by Spring Boot BOM (Oracle 21c driver) | `pom.xml` — `com.oracle.database.jdbc:ojdbc8` |
+| Commons IO | 2.11.0 | `pom.xml` — explicit version |
+| H2 Database | 2.x (managed by Spring Boot 2.7.18 BOM) | `pom.xml` — test scope |
+| Maven | 3.9.6 (Docker build stage) | `Dockerfile` — `FROM maven:3.9.6-eclipse-temurin-8` |
+| Maven (local) | ≥ 3.x (not pinned) | System-installed; used for `mvn test` |
+| Docker base image (build) | `maven:3.9.6-eclipse-temurin-8` | `Dockerfile` |
+| Docker base image (runtime) | `eclipse-temurin:8-jre` | `Dockerfile` |
+| Oracle Database | Free 23ai (`gvenzl/oracle-free:latest`) | `docker-compose.yml` |
diff --git a/.github/modernize/assessment/reports/report-20260521055101/facts/data-architecture.md b/.github/modernize/assessment/reports/report-20260521055101/facts/data-architecture.md
new file mode 100644
index 000000000..9665cbb9e
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/facts/data-architecture.md
@@ -0,0 +1,79 @@
+# Data Architecture & Persistence Layer
+
+PhotoAlbum-Java has a single JPA entity (`Photo`) persisted in Oracle Database using Hibernate 5.6 via Spring Data JPA, storing image binary content as a BLOB column alongside metadata fields.
+
+## Database Configuration
+
+| Service/Module | DB Type | Profile | Driver | Connection | Migration Tool |
+|---------------|---------|---------|--------|-----------|---------------|
+| photoalbum-java-app | Oracle Database Free (FREEPDB1) | default (local) | ojdbc8 (Oracle JDBC) | `jdbc:oracle:thin:@oracle-db:1521/FREEPDB1` | None — Hibernate `ddl-auto=create` recreates schema on startup |
+| photoalbum-java-app | Oracle Database Free (XE) | docker | ojdbc8 (Oracle JDBC) | `jdbc:oracle:thin:@oracle-db:1521:XE` | None — Hibernate `ddl-auto=create` recreates schema on startup |
+| photoalbum-java-app | H2 (in-memory) | test | H2 JDBC Driver | `jdbc:h2:mem:testdb` | None — Hibernate `ddl-auto=create-drop` manages test schema |
+
+Schema management is handled entirely by Hibernate DDL auto-generation (`create` mode in runtime profiles, `create-drop` for tests). No Flyway or Liquibase migration tool is present; the `photos` table is dropped and recreated on every application startup, making this configuration unsuitable for production use without persistent data. The Oracle user (`photoalbum`) is provisioned by the init script `oracle-init/01-create-user.sql`, which is executed automatically when the Oracle container starts. No seed data files (`data.sql`, `import.sql`) are present. For full property key-value details see `configuration-inventory.md`.
+
+## Data Ownership per Service
+
+| Service | Tables Owned | ORM Framework | Caching | Notes |
+|---------|-------------|--------------|---------|-------|
+| photoalbum-java-app | `photos` | Hibernate 5.6 via Spring Data JPA | None | Single table stores all photo metadata and BLOB data; schema auto-generated by Hibernate on startup |
+
+## Entity Model
+
+```mermaid
+erDiagram
+ PHOTOS {
+ string id PK "UUID (length 36)"
+ string original_file_name "NOT NULL, max 255"
+ blob photo_data "nullable BLOB - binary image content"
+ string stored_file_name "NOT NULL, max 255 - GUID-based filename"
+ string file_path "nullable, max 500 - compatibility only"
+ number file_size "NOT NULL, NUMBER(19,0)"
+ string mime_type "NOT NULL, max 50"
+ timestamp uploaded_at "NOT NULL, DEFAULT SYSTIMESTAMP"
+ number width "nullable - image width in pixels"
+ number height "nullable - image height in pixels"
+ }
+```
+
+**Entity notes:**
+- `Photo` is annotated `@Entity @Table(name = "photos")` with a composite index on `uploaded_at` (`idx_photos_uploaded_at`).
+- The primary key `id` is a UUID string assigned in the default constructor (`UUID.randomUUID().toString()`); no `@GeneratedValue` strategy is used.
+- `photoData` is mapped with `@Lob` as a `byte[]`, storing the full binary image directly in Oracle.
+- `@Transactional` is applied at the `PhotoServiceImpl` class level; read-only methods override with `@Transactional(readOnly = true)`.
+
+## Key Repository Methods
+
+| Repository | Return Type | Method Signature | Purpose |
+|-----------|-------------|-----------------|---------|
+| PhotoRepository | `List` | `findAllOrderByUploadedAtDesc()` | Lists all photos newest-first for the gallery home page (native Oracle SQL) |
+| PhotoRepository | `Optional` | `findById(String id)` (inherited) | Fetches a single photo by UUID for detail view and file serving |
+| PhotoRepository | `List` | `findPhotosUploadedBefore(LocalDateTime uploadedAt)` | Returns up to 10 photos older than the given timestamp for backward navigation (uses Oracle `ROWNUM`) |
+| PhotoRepository | `List` | `findPhotosUploadedAfter(LocalDateTime uploadedAt)` | Returns photos newer than the given timestamp for forward navigation |
+| PhotoRepository | `List` | `findPhotosByUploadMonth(String year, String month)` | Filters photos by year/month using Oracle `TO_CHAR` — declared but not currently called from any controller |
+| PhotoRepository | `List` | `findPhotosWithPagination(int startRow, int endRow)` | Oracle `ROWNUM`-based pagination — declared but not currently called from any controller |
+| PhotoRepository | `List` | `findPhotosWithStatistics()` | Returns photos with `RANK() OVER` and running `SUM` analytical functions — declared but not currently called |
+| PhotoRepository | `void` | `delete(Photo)` (inherited) | Removes a photo record (and its BLOB) from the database |
+| PhotoRepository | `Photo` | `save(Photo)` (inherited) | Inserts or updates a photo record |
+
+All custom methods use `@Query(nativeQuery = true)` with Oracle-specific SQL. Source file: `src/main/java/com/photoalbum/repository/PhotoRepository.java`.
+
+## Caching Strategy
+
+No caching layer is configured. There are no `@Cacheable`, `@CacheEvict`, or `@CachePut` annotations, no cache provider (EhCache, Redis, Caffeine) on the classpath, and no Hibernate second-level cache configuration. Every HTTP request to the gallery home page (`GET /`) triggers a full `SELECT ... FROM PHOTOS ORDER BY UPLOADED_AT DESC` query against Oracle, and every `GET /photo/{id}` retrieves the full BLOB from the database. The `PhotoFileController` sets aggressive `Cache-Control: no-cache, no-store` response headers, which also prevents browser-side caching of image binaries.
+
+## Data Ownership Boundaries
+
+**Single-service, single-database topology.** There is only one deployable application service and one database. All data is owned by `photoalbum-java-app` in a single Oracle schema (`photoalbum` user). There are no cross-service data access patterns, shared databases, or inter-service queries.
+
+**Read/write pattern:** All reads and writes go through `PhotoRepository` (Spring Data JPA backed by Hibernate). There is no CQRS separation, read replica, or event sourcing; the same Oracle instance handles both queries and mutations.
+
+**Schema management risk:** Hibernate `ddl-auto=create` drops and recreates the `photos` table (including all BLOB data) on every application restart in both the default and Docker profiles. This is a critical data-loss risk in any environment where photos are expected to persist across restarts.
+
+### Data Classification & Sensitivity
+
+| Entity | Sensitive Fields | Classification | Controls in Place |
+|--------|----------------|---------------|-------------------|
+| Photo | `original_file_name` (user-provided filename), `photo_data` (image BLOB — may contain EXIF metadata including GPS coordinates, device identifiers) | Potentially PII (EXIF data embedded in images) | None — no encryption-at-rest, no EXIF stripping, no field-level access control, no masking |
+
+The `photos` table does not store explicit PII such as usernames, email addresses, or personal identifiers. However, uploaded image files may contain EXIF metadata (GPS location, device serial number, timestamps) embedded in the binary data, which constitutes PII under GDPR. The application reads image bytes directly via `ImageIO` to extract pixel dimensions but does not strip EXIF data before persisting the BLOB. No encryption-at-rest is configured for the Oracle tablespace, and there is no authentication layer protecting access to stored images.
diff --git a/.github/modernize/assessment/reports/report-20260521055101/facts/dependency-map.md b/.github/modernize/assessment/reports/report-20260521055101/facts/dependency-map.md
new file mode 100644
index 000000000..805515c9c
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/facts/dependency-map.md
@@ -0,0 +1,74 @@
+# Dependency Map
+
+PhotoAlbum-Java declares **10 dependencies** (8 production + 2 test-scoped) managed via Maven with the Spring Boot 2.7.18 parent BOM.
+
+## Dependencies
+
+```mermaid
+flowchart LR
+ App["PhotoAlbum\nSpring Boot 2.7.18"]
+
+ subgraph BOM["Parent BOM"]
+ ParentBOM["spring-boot-starter-parent\nv2.7.18"]
+ end
+ subgraph Web["Web Frameworks"]
+ SpringWeb["spring-boot-starter-web\n(Spring MVC + Tomcat)"]
+ Thymeleaf["spring-boot-starter-thymeleaf\n(Thymeleaf 3.x)"]
+ end
+ subgraph DB["Database / ORM"]
+ JPA["spring-boot-starter-data-jpa\n(Hibernate 5.6.x)"]
+ OracleJDBC["ojdbc8\n(Oracle JDBC - runtime)"]
+ end
+ subgraph Val["Validation"]
+ Validation["spring-boot-starter-validation\n(Hibernate Validator 6.x)"]
+ end
+ subgraph Util["Utilities"]
+ CommonsIO["commons-io\nv2.11.0"]
+ Jackson["spring-boot-starter-json\n(Jackson 2.14.x)"]
+ DevTools["spring-boot-devtools\n(optional - dev only)"]
+ end
+
+ ParentBOM -.->|"manages versions"| SpringWeb
+ ParentBOM -.->|"manages versions"| Thymeleaf
+ ParentBOM -.->|"manages versions"| JPA
+ ParentBOM -.->|"manages versions"| OracleJDBC
+ ParentBOM -.->|"manages versions"| Validation
+ ParentBOM -.->|"manages versions"| Jackson
+
+ App -->|"BOM"| BOM
+ App -->|"web"| Web
+ App -->|"persistence"| DB
+ App -->|"validation"| Val
+ App -->|"utilities"| Util
+```
+
+### Dependency Summary
+
+| Category | Count | Key Libraries | Notes |
+|----------|-------|--------------|-------|
+| Web Frameworks | 2 | Spring MVC (via spring-boot-starter-web), Thymeleaf 3.x | Legacy Spring Boot 2.x stack; Spring Boot 2.7.x reaches end-of-life Aug 2023 |
+| Database / ORM | 2 | Hibernate 5.6.x (via JPA starter), ojdbc8 (Oracle JDBC) | Oracle-specific dialect and native queries throughout; tightly coupled to Oracle |
+| Validation | 1 | Hibernate Validator 6.x (via validation starter) | Jakarta EE 8 / `javax.validation` namespace — must migrate to `jakarta.validation` for Spring Boot 3 |
+| Utilities | 3 | commons-io 2.11.0, Jackson 2.14.x, spring-boot-devtools | commons-io 2.11.0 is stable; devtools is optional/dev-only |
+
+### Version & Compatibility Risks
+
+Spring Boot 2.7.18 is the final 2.x release and reached **commercial end-of-life in August 2023** (OSS support ended February 2023). Migrating to Spring Boot 3.x requires upgrading the Java baseline to **Java 17** (from the current Java 8) and switching all `javax.*` imports to the `jakarta.*` namespace (EE 9+). Hibernate 5.6.x is also end-of-life; Spring Boot 3 bundles Hibernate 6, which has breaking API changes. The Oracle JDBC driver (`ojdbc8`) must be kept in sync with the Oracle server version; however the dependency version is managed by the Spring Boot BOM rather than declared explicitly, which may lag behind the latest certified driver. Commons IO 2.11.0 has no known CVEs but a newer 2.15.x line is available.
+
+### Notable Observations
+
+- **Java 8 baseline**: The application targets Java 8 (`maven.compiler.source=8`), which is significantly behind the current Java LTS (Java 21). Any migration to Spring Boot 3 mandates a minimum of Java 17; this represents a substantial upgrade effort.
+- **Oracle vendor lock-in**: The use of `ojdbc8`, Oracle-specific SQL (`ROWNUM`, `TO_CHAR`, `NVL`, `RANK() OVER`), and `OracleDialect` makes the data layer non-portable. Migrating to a managed cloud database (e.g., Azure Database for PostgreSQL) would require rewriting all native queries.
+- **No caching or messaging libraries**: The application has no declared caching (e.g., Redis/EhCache) or messaging (e.g., Kafka/Service Bus) dependencies. All photo data is fetched directly from Oracle on every request, which may become a performance bottleneck at scale.
+- **No security framework**: There is no Spring Security or equivalent dependency declared. The application has no authentication or authorization layer, meaning all endpoints are publicly accessible.
+
+## Test Dependencies
+
+| Framework | Version | Notes |
+|-----------|---------|-------|
+| spring-boot-starter-test | 2.7.18 (managed) | Bundles JUnit 5 (Jupiter), Mockito, AssertJ, Spring Test |
+| H2 Database | 2.x (managed by BOM) | In-memory database used as Oracle substitute in tests |
+
+Total test-scope dependencies: **2**
+
+The test setup relies on H2 as a stand-in for Oracle, which may hide Oracle-specific query incompatibilities (native SQL using `ROWNUM`, `TO_CHAR`, Oracle analytical functions) during unit testing. No integration testing framework (e.g., Testcontainers with an Oracle image) is present, meaning Oracle-specific code paths are not exercised in CI.
diff --git a/.github/modernize/assessment/reports/report-20260521055101/report.html b/.github/modernize/assessment/reports/report-20260521055101/report.html
new file mode 100644
index 000000000..b5386b0b6
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/report.html
@@ -0,0 +1,1542 @@
+
+
+
+Assessment - photo-album
+
+
+
+
+
+
✕
+
Scroll to zoom · Drag to pan · Double-click to reset
+
+
+
+
photo-album
+
+
Application Information
+
+
+
+
Application Name photo-album
+
Java Version 1.8
+
Effort L (total story points: 51)
+
+
+
Build Tools Maven
+
Frameworks Spring Boot, Spring
+
+
+
+
+
+ Issues
+ Architecture
+ API Contracts
+ Configuration
+ Business Workflows
+ Dependencies
+ Data Model
+
+
+
+ Target Compute Service:
+
+ Azure Kubernetes Service
+ Azure App Service
+ Azure Container Apps
+
+
+
+
Issue Summary
+
+
+
+
+
+
+
+
Cloud Readiness
+
5 issues
+
+
+
+
+
+
+
+
Java Upgrade
+
4 issues
+
+
+
+
Mandatory
+
Potential
+
Optional
+
+
+
+
+
+
Cloud Readiness
+
+
+ Issue Category Criticality Story Point
+
+
+ ❯ Oracle database found
+ Potential
+ 8
+
+
+
+
+
+
+ File Position
+ pom.xml Line 52
+ src/main/resources/application.properties Line 10
+ docker-compose.yml Line 32
+ src/main/resources/application-docker.properties Line 2
+ src/main/resources/application-docker.properties Line 5
+ src/main/resources/application.properties Line 13
+
+
+
+
Explanation
+
Oracle database found. To migrate a Java application that uses an Oracle database to Azure, you can follow these recommendations:
* Migrate to Azure Database for PostgreSQL : Azure recommends migrating Oracle databases to Azure Database for PostgreSQL Flexible Server as it provides better cost-effectiveness and performance. Create a managed PostgreSQL Flexible Server database in Azure and choose the appropriate pricing tier based on your application's requirements.
* Use migration tools : Utilize the Azure Database Migration Service (DMS) or third-party tools to migrate your Oracle database schema and data to PostgreSQL. Consider using ora2pg or similar tools to convert Oracle-specific SQL to PostgreSQL-compatible SQL.
* Update database drivers and connection strings : Replace Oracle JDBC drivers with PostgreSQL drivers in your Java application. Update connection strings from Oracle format (jdbc:oracle:thin:) to PostgreSQL format (jdbc:postgresql:).
* Review and convert Oracle-specific code : Identify and convert Oracle-specific SQL functions, stored procedures, and PL/SQL code to PostgreSQL equivalents. Pay attention to data types, syntax differences, and built-in functions.
* Enable monitoring and diagnostics : Utilize Azure Monitor to gain insights into the performance and health of your Java application and the underlying PostgreSQL database. Set up metrics, alerts, and log analytics to proactively identify and resolve issues.
* Implement security measures: Apply security best practices to protect your Java application and the PostgreSQL database. This includes implementing authentication and authorization mechanisms with passwordless connections and leveraging Microsoft Defender for Cloud for threat detection and vulnerability assessments.
* Backup your data: Azure Database for PostgreSQL provides automated backups by default. You can configure the retention period for backups based on your requirements. You can also enable geo-redundant backups, if needed, to enhance data durability and availability.
+
+
+
+
+
+ ❯ Password found in configuration file
+ Potential
+ 3
+
+
+
+
+
+
+ File Position
+ src/test/resources/application-test.properties Line 5
+ src/main/resources/application-docker.properties Line 4
+ src/main/resources/application.properties Line 12
+
+
+
+
Explanation
+
Using clear passwords in property files is a security risk, as they can be easily compromised if the files are accessed by unauthorized individuals. To enhance the security of your application, it is recommended to employ secure credential management practices.
* Azure Key Vault : Utilize Azure Key Vault to securely store and manage your application's passwords and other sensitive credentials. Azure Key Vault provides a centralized and highly secure location for storing secrets, keys, and certificates.
* Passwordless connections : You can provide an additional layer of security and convenience for accessing resources in Azure by eliminating the need for passwords. This way you can reduce the risk of password-related vulnerabilities, such as weak passwords or password theft.
+
+
+
+
+
+ ❯ Server port configuration found
+ Potential
+ 1
+
+
+
+
+
+
+ File Position
+ src/main/resources/application-docker.properties Line 24
+ src/main/resources/application.properties Line 2
+
+
+
+
Explanation
+
The application is setting the server port. To migrate a Java application that sets the server port to Azure Container Apps:
*Azure Container Apps allows you to expose port according to your Azure Container Apps resource configuration. For instance, a Spring Boot application listens to port of 8080 by default, but it can be set with server.port or environment variable SERVER_PORT as you need.
+
+
+
+
+
+ ❯ Restricted configurations found
+ Potential
+ 2
+
+
+
+
+
+
+ File Position
+ src/main/resources/application-docker.properties Line 24
+ src/main/resources/application.properties Line 2
+
+
+
+
Explanation
+
The application uses restricted configurations for Azure Container Apps. These properties can be automatically injected into your application environment by Azure Container Apps to access managed Config Server and managed Eureka Server. Please remove them from your application, including configuration files, config server files, command line parameters, Java system attributes, and environment variables.
If configured in configuration files : they will be ignored and overrided by Azure Container Apps.
If configured in Config Server files , command line parameters , Java system attribute , environment variable : they need to be removed or you might experience conflicts and unexpected behavior.
+
+
+
+
+
+ ❯ Detects usage of Jakarta Persistence (JPA) APIs
+ Potential
+ 5
+
+
+
+
+
+
+ File Position
+ pom.xml Line 46
+
+
+
+
Explanation
+
The application depends on Jakarta Persistence (JPA) APIs (jakarta.persistence. or legacy javax.persistence.), which are used for object-relational mapping (ORM) and database interaction in Jakarta EE or Java EE applications.
When migrating to Azure:
Ensure that the database connection, JPA provider (e.g., Hibernate, EclipseLink), and dialect are compatible with your target Azure database service. Recommended database services include Azure Database for PostgreSQL , Azure Database for MySQL , or Azure SQL Database . For containerized deployments, these JPA-based applications can run on Azure Kubernetes Service (AKS) or Azure App Service for Linux/Windows . If using Spring Data JPA , verify that connection pool settings and environment variables are properly configured for the cloud environment. Consider leveraging Azure Key Vault for secure storage of database credentials and connection strings.
+
+
+
+
+
+
+
+
+
Java Upgrade
+
+
+ Issue Category Criticality Story Point
+
+
+ ❯ Spring Boot Version Has Reached the End of OSS Support
+ Mandatory
+ 8
+
+
+
+
+
+
+ File Position
+ pom.xml Line 59
+ pom.xml Line 72
+ pom.xml Line 46
+ pom.xml Line 34
+ pom.xml Line 78
+ pom.xml Line 92
+ pom.xml Line 40
+
+
+
+
Explanation
+
The application is using a Spring Boot version that has reached its End of OSS Support. With the officially supported new versions from Spring, you can get the best experience. Here are some steps you can take to update your application to the latest version of Spring Boot:
Choose a supported Spring Boot version : Check out Spring Boot Support Versions and determine the most suitable supported Spring Boot version. Update Spring Boot version : Update the Spring Boot version of your application. There are automated tools like Rewrite to help you with the migration.Address code compatibility : Review your application's codebase for any potential compatibility issues with the target Spring Boot version. Update deprecated APIs or features, address any language or library changes, and ensure that your code follows best practices and standards.Test thoroughly : Execute a comprehensive testing process to verify the compatibility and functionality of your application with the new Spring Boot version. Perform unit tests, integration tests, and system tests to validate that all components and dependencies work as expected.
+
+
+
+
+
+ ❯ Spring Framework Version Has Reached the End of OSS Support
+ Mandatory
+ 8
+
+
+
+
+
+
+ File Position
+ pom.xml Line 46
+ pom.xml Line 34
+ pom.xml Line 78
+
+
+
+
Explanation
+
Your application is using a Spring Framework version that has reached its End of OSS Support. Upgrading to a supported version ensures better performance, security, and compatibility with modern tools. 1. Pick a Supported Version: Review the Spring Framework support policy and choose an actively supported version. 2. Update Your Project: Change the Spring Framework version in your pom.xml or build.gradle. 3. Fix Compatibility Issues: Update deprecated code, replace removed features, and ensure dependencies are compatible with the new Spring Framework version. 4. Thoroughly Test: Run unit, integration, and end-to-end tests to make sure everything still works after the upgrade.
+
+
+
+
+
+ ❯ Java Version Has Reached the End of Support
+ Mandatory
+ 8
+
+
+
+
+
+
+ File Position
+ pom.xml Line 24
+
+
+
+
Explanation
+
The application is using a Java version that has reached the end of support. It is strongly recommended to plan and execute a migration strategy to upgrade your application to a supported Java version. Supported Java versions receive long-term support (LTS) from the Java community, including bug fixes and updates. Migrating to a supported version provides you with a stable and well-maintained platform for your application.
+
+
+
+
+
+ ❯ Java Version is not the latest LTS
+ Optional
+ 8
+
+
+
+
+
+
+ File Position
+ pom.xml Line 25
+ pom.xml Line 26
+
+
+
+
Explanation
+
The application is not using the latest LTS Java version. It is recommended to consider upgrading to the latest LTS version to take advantage of the newest language features, performance improvements, and extended support timelines. Upgrading to the latest LTS version ensures your application benefits from the most recent security enhancements and a longer support lifecycle.
+
+
+
+
+
+
+
+
+
+
Architecture Diagram
+
PhotoAlbum-Java is a Spring Boot 2.7 web application that allows users to upload, browse, and manage photos, storing image data as BLOBs in an Oracle database.
+
Application Architecture
+
+flowchart TD
+ subgraph Client["Client Layer"]
+ Browser["Web Browser"]
+ end
+ subgraph App["Application Layer - Spring Boot 2.7 / Java 8"]
+ Web["Spring MVC Controllers\n(HomeController, DetailController,\nPhotoFileController)"]
+ Template["Thymeleaf Templates\n(index, detail, layout)"]
+ Service["Business Services\n(PhotoServiceImpl)"]
+ Validation["Bean Validation\n(Spring Validator)"]
+ end
+ subgraph Data["Data Layer"]
+ JPA["Spring Data JPA\n(PhotoRepository)"]
+ DB[("Oracle Database\n(FREEPDB1 / BLOB storage)")]
+ end
+
+ Browser -->|"HTTP GET / POST"| Web
+ Web -->|"renders"| Template
+ Web -->|"delegates"| Service
+ Service -->|"validates"| Validation
+ Service -->|"CRUD + native queries"| JPA
+ JPA -->|"JDBC / ojdbc8"| DB
+
+
Technology Stack Summary
+
+Layer Technology Version Purpose
+
+Presentation Spring MVC + Thymeleaf 2.7.18 / 3.x Server-side rendering and REST endpoints
+Business Logic Spring Boot Service 2.7.18 Photo upload, retrieval, deletion, navigation
+Data Access Spring Data JPA / Hibernate 2.7.18 ORM and query abstraction
+Database Oracle Database (FREEPDB1) Oracle XE / Free Persistent storage for photo metadata and BLOBs
+Runtime Java 8 Application runtime
+Build Apache Maven 3.x Dependency management and build
+Testing JUnit 5 + H2 Spring Boot 2.7.18 Unit and integration tests
+
+
Data Storage & External Services
+
Photos and their metadata (filename, MIME type, dimensions, upload timestamp) are stored entirely within an Oracle Database instance. The binary image content is persisted as a BLOB (byte[] mapped via @Lob) in the photos table, eliminating the need for a file system or external object store. The application connects to Oracle via the ojdbc8 JDBC driver using a fixed data source configured in application.properties. No external caches, message brokers, or third-party APIs are used; all data flows are internal between the Spring Boot process and the Oracle instance.
+
Key Architectural Decisions
+
+BLOB storage in Oracle : Images are stored directly in the database as byte arrays rather than on disk or in cloud object storage, simplifying deployment but coupling the app tightly to Oracle.
+Native SQL queries for Oracle-specific features : The PhotoRepository uses Oracle-specific functions (ROWNUM, TO_CHAR, NVL, analytical RANK() OVER) in native queries to implement pagination, filtering, and ranking.
+Constructor injection + @Transactional service : PhotoServiceImpl receives all dependencies via constructor and is annotated @Transactional, following standard Spring best practices for testability and transaction management.
+
+
Component Relationships
+
+flowchart LR
+ subgraph Presentation["Presentation Layer"]
+ HomeCtrl["HomeController"]
+ DetailCtrl["DetailController"]
+ PhotoFileCtrl["PhotoFileController"]
+ end
+ subgraph Business["Business Logic Layer"]
+ PhotoSvc["PhotoService (interface)"]
+ PhotoSvcImpl["PhotoServiceImpl"]
+ end
+ subgraph DataAccess["Data Access Layer"]
+ PhotoRepo["PhotoRepository\n(JpaRepository)"]
+ end
+ subgraph Model["Domain Model"]
+ PhotoEntity["Photo (JPA Entity)"]
+ UploadResult["UploadResult (DTO)"]
+ end
+
+ HomeCtrl -->|"delegates upload/list"| PhotoSvc
+ DetailCtrl -->|"delegates view/delete/nav"| PhotoSvc
+ PhotoFileCtrl -->|"delegates file serve"| PhotoSvc
+ PhotoSvc -.->|"implemented by"| PhotoSvcImpl
+ PhotoSvcImpl -->|"queries/saves"| PhotoRepo
+ PhotoRepo -->|"maps to/from"| PhotoEntity
+ PhotoSvcImpl -->|"produces"| UploadResult
+ HomeCtrl -->|"returns"| UploadResult
+
+
Component Inventory
+
+Component Layer Type Responsibility
+
+HomeController Presentation Spring MVC Controller Handles GET / (gallery list) and POST /upload (multi-file upload); returns HTML view or JSON
+DetailController Presentation Spring MVC Controller Handles GET /detail/{id} (single photo view) and POST /detail/{id}/delete (photo deletion)
+PhotoFileController Presentation Spring MVC Controller Handles GET /photo/{id} to stream BLOB photo data with appropriate Content-Type headers
+PhotoService Business Logic Service Interface Defines contract for all photo operations (list, get, upload, delete, navigation)
+PhotoServiceImpl Business Logic Service Implementation Validates files (MIME type, size), reads bytes, extracts dimensions via ImageIO, persists via repository
+PhotoRepository Data Access Spring Data JPA Repository Extends JpaRepository; provides CRUD plus Oracle-specific native queries for ordering, pagination, and statistics
+Photo Domain Model JPA Entity Maps to photos table; holds metadata and @Lob binary image data
+UploadResult Domain Model DTO Carries upload outcome (success flag, photo ID or error message) between service and controller
+MathUtil Utility Utility Class General-purpose math helper utilities
+
+
+
+
API & Service Communication Contracts
+
PhotoAlbum-Java exposes 5 HTTP endpoints across three Spring MVC controllers; all communication is synchronous REST over HTTP with no API gateway, message broker, or external service integrations.
+
Service Catalog
+
+Service Port Category Purpose
+
+photoalbum-java-app 8080 Business Spring Boot web application serving the photo gallery UI and REST upload API
+oracle-db 1521 Infrastructure Oracle Database Free (third-party container) providing persistent BLOB and metadata storage
+
+
API Endpoints Inventory
+
+Service Method Path Request Type Response Type
+
+HomeController GET /— HTML view (index.html) with List model
+HomeController POST /uploadmultipart/form-data — files param (List)JSON 200 OK — {success, uploadedPhotos[], failedUploads[]} or 400 Bad Request
+DetailController GET /detail/{id}Path param id (String UUID) HTML view (detail.html) with Photo model + nav IDs; redirects to / on not-found
+DetailController POST /detail/{id}/deletePath param id (String UUID) Redirect 302 to / with flash attributes
+PhotoFileController GET /photo/{id}Path param id (String UUID) Binary image response (image/jpeg, image/png, etc.) with Cache-Control: no-store headers; 404 if not found
+
+
Management & Observability Endpoints
+
+Service Endpoint Custom Metrics
+
+photoalbum-java-app None — Spring Boot Actuator is not on the classpath None declared
+
+
No /actuator/health, /actuator/metrics, or /actuator/prometheus endpoints are available. There are no @Timed or custom Micrometer metric registrations in the codebase.
+
DTOs & Contracts
+
Service-level domain classes used in the API:
+
+Photo (JPA Entity / response model): Returned directly from service layer to controllers and rendered in Thymeleaf templates or serialized to JSON in the upload response. Not immutable — uses standard mutable POJO with getters/setters. Full field details are in data-architecture.md.
+UploadResult (response DTO): Carries the outcome of a single-file upload operation: success (boolean), fileName (String), photoId (String UUID on success), and errorMessage (String on failure). Mutable POJO; provides a failure(...) static factory method. Consumed by HomeController to build the JSON upload response.
+
+
No OpenAPI/Swagger specification, protobuf schemas, or GraphQL schemas are present. Jackson (via spring-boot-starter-json) handles JSON serialization for the upload response endpoint using default settings — no custom serializers or ObjectMapper configuration is declared.
+
Communication Patterns
+
Synchronous only. All client-to-application communication is HTTP/1.1 REST. The application makes no outbound HTTP calls to other services; all downstream communication is via JDBC to Oracle.
+
Resilience: No circuit breaker, retry policy, bulkhead, or timeout configuration is declared (no Resilience4j, Spring Retry, or equivalent). Failed database operations propagate as unchecked RuntimeException to the controller, which returns a 500 response or redirects to the home page.
+
Service discovery: Not applicable — single-service deployment. The Oracle JDBC URL is hardcoded in application.properties (default profile) and overridden via environment variable SPRING_DATASOURCE_URL in the Docker profile.
+
Startup dependency chain: The docker-compose.yml configures photoalbum-java-app to wait for oracle-db to pass its health check (condition: service_healthy) before starting. No application-level readiness probe is registered. For full Docker configuration details see configuration-inventory.md.
+
Security posture: No authentication, authorization, or TLS is configured. Spring Security is not on the classpath. All five endpoints — including photo deletion (POST /detail/{id}/delete) and file upload (POST /upload) — are publicly accessible with no authorization checks. There is no CSRF protection and no HTTPS configuration.
+
Service Technology Matrix
+
+Service Web Framework Data Access Discovery Gateway Actuator/Health Cache Metrics
+
+photoalbum-java-app Spring MVC (servlet) Spring Data JPA + Hibernate 5.6 None None None None None
+oracle-db N/A (third-party) N/A N/A N/A Docker healthcheck only N/A N/A
+
+
Service Communication Sequence
+
+sequenceDiagram
+ participant Client as "Browser / HTTP Client"
+ participant HomeCtrl as "HomeController"
+ participant DetailCtrl as "DetailController"
+ participant FileCtrl as "PhotoFileController"
+ participant PhotoSvc as "PhotoServiceImpl"
+ participant Repo as "PhotoRepository"
+ participant DB as "Oracle Database"
+
+ Note over Client,DB: Gallery page load
+ Client->>HomeCtrl: GET /
+ HomeCtrl->>PhotoSvc: getAllPhotos()
+ PhotoSvc->>Repo: findAllOrderByUploadedAtDesc()
+ Repo->>DB: SELECT ... FROM PHOTOS ORDER BY UPLOADED_AT DESC
+ DB-->>Repo: List of Photo rows
+ Repo-->>PhotoSvc: List
+ PhotoSvc-->>HomeCtrl: List
+ HomeCtrl-->>Client: 200 HTML (index.html, photo grid)
+
+ Note over Client,DB: Photo upload
+ Client->>HomeCtrl: POST /upload (multipart files)
+ loop For each file
+ HomeCtrl->>PhotoSvc: uploadPhoto(MultipartFile)
+ PhotoSvc->>PhotoSvc: Validate MIME type and size
+ alt Validation fails
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, error)
+ else Validation passes
+ PhotoSvc->>Repo: save(Photo with BLOB data)
+ Repo->>DB: INSERT INTO PHOTOS (... photo_data BLOB ...)
+ DB-->>Repo: saved Photo
+ Repo-->>PhotoSvc: Photo
+ PhotoSvc-->>HomeCtrl: UploadResult(success=true, photoId)
+ end
+ end
+ HomeCtrl-->>Client: 200 JSON {success, uploadedPhotos[], failedUploads[]}
+
+ Note over Client,DB: Serve photo binary
+ Client->>FileCtrl: GET /photo/{id}
+ FileCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>Repo: findById(id)
+ Repo->>DB: SELECT ... FROM PHOTOS WHERE ID = ?
+ DB-->>Repo: Photo row with BLOB
+ Repo-->>PhotoSvc: Optional
+ alt Photo found
+ PhotoSvc-->>FileCtrl: Optional.of(Photo)
+ FileCtrl-->>Client: 200 image/jpeg (binary BLOB data, no-cache headers)
+ else Not found
+ PhotoSvc-->>FileCtrl: Optional.empty()
+ FileCtrl-->>Client: 404 Not Found
+ end
+
+ Note over Client,DB: Delete photo
+ Client->>DetailCtrl: POST /detail/{id}/delete
+ DetailCtrl->>PhotoSvc: deletePhoto(id)
+ PhotoSvc->>Repo: delete(Photo)
+ Repo->>DB: DELETE FROM PHOTOS WHERE ID = ?
+ DB-->>Repo: OK
+ Repo-->>PhotoSvc: void
+ PhotoSvc-->>DetailCtrl: true
+ DetailCtrl-->>Client: 302 Redirect to /
+
+
+
+
Configuration & Externalized Settings Inventory
+
PhotoAlbum-Java uses three Spring Boot property files (default, docker, test profiles) as its sole configuration source, with secrets supplied via plain-text properties and Docker environment variable overrides — no external config server or secret store is employed.
+
Configuration Sources
+
+Source Type Path / Location Notes
+
+application.propertiesSpring Boot default profile src/main/resources/application.propertiesActive in local development; connects to Oracle at oracle-db:1521/FREEPDB1
+application-docker.propertiesSpring Boot docker profile src/main/resources/application-docker.propertiesActivated via SPRING_PROFILES_ACTIVE=docker in Docker Compose; overrides JDBC URL to oracle-db:1521:XE
+application-test.propertiesSpring Boot test profile src/test/resources/application-test.propertiesActive during mvn test; substitutes Oracle with H2 in-memory DB
+docker-compose.ymlDocker Compose environment docker-compose.yml (root)Injects SPRING_PROFILES_ACTIVE, SPRING_DATASOURCE_URL/USERNAME/PASSWORD into the app container; sets Oracle init-db environment variables
+oracle-init/01-create-user.sqlOracle init script oracle-init/01-create-user.sqlExecuted automatically by the Oracle Free container on first start; creates photoalbum user with DBA privileges
+oracle-init/02-verify-user.sqlOracle init script oracle-init/02-verify-user.sqlPost-creation verification query
+DockerfileContainer build config Dockerfile (root)Multi-stage build; sets default JAVA_OPTS=-Xmx512m -Xms256m
+
+
No Spring Cloud Config server, Kubernetes ConfigMaps, HashiCorp Vault, Azure Key Vault, or AWS Secrets Manager references are present. No bootstrap.properties or bootstrap.yml files exist.
+
Build Profiles
+
+Profile Activation Purpose Key Dependencies / Plugins
+
+(default) Automatic — no -P flag required Standard local build with all dependencies; runs tests against H2 spring-boot-maven-plugin for executable JAR; spring-boot-starter-test + H2 in test scope
+Docker multi-stage build Triggered by docker build / docker-compose up --build Compiles and packages in maven:3.9.6-eclipse-temurin-8; copies JAR to eclipse-temurin:8-jre runtime image mvn clean package -DskipTests (skips tests inside Docker build); no additional Maven profiles declared in pom.xml
+
+
No explicit Maven blocks are declared in pom.xml. The only build variation is between a local Maven build (runs tests) and the Docker-contained build (skips tests).
+
Runtime Profiles
+
+Profile Activation Method Config Files Key Overrides vs Default
+
+(default) None — active when no profile is set application.propertiesBaseline — Oracle at oracle-db:1521/FREEPDB1, log level DEBUG for app code
+dockerSPRING_PROFILES_ACTIVE=docker (set in docker-compose.yml)application.properties + application-docker.propertiesJDBC URL changed to oracle-db:1521:XE; app log level reduced to INFO; Hibernate SQL log to DEBUG
+testApplied automatically by spring-boot-starter-test via application-test.properties in test classpath application-test.propertiesOracle replaced with jdbc:h2:mem:testdb; ddl-auto=create-drop; SQL logging disabled; upload path set to target/test-uploads
+
+
Multiple active profiles are not combined in any declared configuration. The docker profile fully composes with the base application.properties (Spring Boot merges them).
+
Properties Inventory
+
photoalbum-java-app — Server & Encoding
+
+Property Key Default docker profile test profile Source
+
+server.port80808080— application.properties
+server.servlet.encoding.charsetUTF-8UTF-8— application.properties
+server.servlet.encoding.enabledtruetrue— application.properties
+server.servlet.encoding.forcetruetrue— application.properties
+
+
photoalbum-java-app — DataSource
+
+Property Key Default docker profile test profile Source
+
+spring.datasource.urljdbc:oracle:thin:@oracle-db:1521/FREEPDB1jdbc:oracle:thin:@oracle-db:1521:XEjdbc:h2:mem:testdbProfile files
+spring.datasource.usernamephotoalbumphotoalbumsaProfile files
+spring.datasource.passwordphotoalbum [SENSITIVE]photoalbum [SENSITIVE]_(empty)_ Profile files
+spring.datasource.driver-class-nameoracle.jdbc.OracleDriveroracle.jdbc.OracleDriverorg.h2.DriverProfile files
+
+
photoalbum-java-app — JPA / Hibernate
+
+Property Key Default docker profile test profile Source
+
+spring.jpa.database-platformorg.hibernate.dialect.OracleDialectorg.hibernate.dialect.OracleDialectorg.hibernate.dialect.H2DialectProfile files
+spring.jpa.hibernate.ddl-autocreatecreatecreate-dropProfile files
+spring.jpa.show-sqltruetruefalseProfile files
+spring.jpa.properties.hibernate.format_sqltruetrue— Profile files
+
+
photoalbum-java-app — File Upload
+
+Property Key Default docker profile test profile Source
+
+spring.servlet.multipart.max-file-size10MB10MB— application.properties
+spring.servlet.multipart.max-request-size50MB50MB— application.properties
+app.file-upload.max-file-size-bytes104857601048576010485760Profile files
+app.file-upload.allowed-mime-typesimage/jpeg,image/png,image/gif,image/webpsame same Profile files
+app.file-upload.max-files-per-upload101010Profile files
+app.file-upload.upload-path— — target/test-uploadsapplication-test.properties
+
+
photoalbum-java-app — Logging
+
+Property Key Default docker profile test profile Source
+
+logging.level.com.photoalbumDEBUGINFODEBUGProfile files
+logging.level.org.springframework.webDEBUGWARN— Profile files
+logging.level.org.hibernate.SQL— DEBUG— application-docker.properties
+
+
Startup Parameters & Resource Requirements
+
+Service JVM / Runtime Options Memory CPU Instance Count
+
+photoalbum-java-app (Docker) JAVA_OPTS=-Xmx512m -Xms256m (set in Dockerfile ENV)No mem_limit set in Compose Not specified 1 (no scaling config)
+photoalbum-java-app (local Maven) JVM default (no explicit heap flags) Host JVM defaults Not specified 1
+oracle-db Oracle Free container defaults No mem_limit set in Compose Not specified 1
+
+
The JAVA_OPTS environment variable is read by the ENTRYPOINT script (sh -c "java $JAVA_OPTS -jar app.jar"). It can be overridden at docker run time or via docker-compose.yml environment section. No -Dspring.profiles.active JVM system property is used; profile activation is exclusively via SPRING_PROFILES_ACTIVE environment variable.
+
Startup Dependency Chain
+
+oracle-db → (Docker healthcheck: healthcheck.sh, interval 30s, timeout 10s, retries 15, start_period 180s)
+ ↓
+photoalbum-java-app → depends_on: oracle-db (condition: service_healthy)
+ restart policy: on-failure
+
+
+oracle-db starts first. The Docker Compose health check runs healthcheck.sh inside the Oracle container every 30 seconds with a 180-second start period and up to 15 retries (~7.5 minutes total patience).
+photoalbum-java-app only starts after oracle-db reports healthy. No application-level readiness probe (Spring Boot Actuator is not on the classpath). If Oracle is unavailable after the container starts, the Spring application context will fail to initialize (Hibernate ddl-auto=create requires a live connection at startup) and Docker Compose will restart the container per restart: on-failure.
+
+
There is no config-server, discovery-server, or API gateway in the startup chain.
+
Secrets & Sensitive Configuration
+
+Secret Reference Type Profile Storage
+
+spring.datasource.passwordOracle DB password default, docker Plain-text in application.properties / application-docker.properties — value: [MASKED]
+SPRING_DATASOURCE_PASSWORDOracle DB password (env var override) docker (Compose) Plain-text in docker-compose.yml — value: [MASKED]
+ORACLE_PASSWORDOracle root/sys password docker (Compose, oracle-db service) Plain-text in docker-compose.yml — value: [MASKED]
+APP_USER_PASSWORDOracle app-user password docker (Compose, oracle-db service) Plain-text in docker-compose.yml — value: [MASKED]
+
+
Secrets Provisioning Workflow
+
All secrets are stored as plain-text values in source-controlled configuration files (application.properties, docker-compose.yml). There is no secrets management system in use:
+
+No external secret store : No HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, or Kubernetes Secrets are referenced.
+No encryption : No Jasypt, DPAPI, or sealed-secret encryption of property values.
+Credential flow : Oracle credentials are hard-coded in application.properties (default profile) and additionally injected as SPRING_DATASOURCE_* environment variables in docker-compose.yml. The values in both locations are identical plain-text strings.
+Risk : Committing database passwords to source control is a critical security issue. Any developer or CI system with repository read access can obtain the database credentials.
+
+
Recommended remediation : Externalize secrets to a vault (e.g., Azure Key Vault with managed identity, or GitHub Actions secrets injected at deploy time) and remove credential values from all checked-in files.
+
Feature Flags
+
No feature flag framework is present. No @ConditionalOnProperty, @ConditionalOnExpression, LaunchDarkly, Unleash, or custom toggle mechanism is used. The only conditional behaviour is the standard Spring Boot profile activation that selects the appropriate application-{profile}.properties file.
+
+Flag Name Default Controlled By
+
+None detected — —
+
+
Framework & Runtime Versions
+
+Component Version Source
+
+Java (compile target) 8 (1.8) pom.xml — maven.compiler.source/target
+Java (Docker build) 8 (eclipse-temurin:8) Dockerfile — FROM maven:3.9.6-eclipse-temurin-8 / FROM eclipse-temurin:8-jre
+Spring Boot 2.7.18 pom.xml — parent BOM
+Spring MVC 5.3.x (managed by Spring Boot 2.7.18 BOM) Transitive via spring-boot-starter-web
+Hibernate 5.6.x (managed by Spring Boot 2.7.18 BOM) Transitive via spring-boot-starter-data-jpa
+Thymeleaf 3.0.x (managed by Spring Boot 2.7.18 BOM) Transitive via spring-boot-starter-thymeleaf
+Hibernate Validator 6.x (managed by Spring Boot 2.7.18 BOM) Transitive via spring-boot-starter-validation
+Jackson 2.14.x (managed by Spring Boot 2.7.18 BOM) Transitive via spring-boot-starter-json
+Oracle JDBC (ojdbc8) Managed by Spring Boot BOM (Oracle 21c driver) pom.xml — com.oracle.database.jdbc:ojdbc8
+Commons IO 2.11.0 pom.xml — explicit version
+H2 Database 2.x (managed by Spring Boot 2.7.18 BOM) pom.xml — test scope
+Maven 3.9.6 (Docker build stage) Dockerfile — FROM maven:3.9.6-eclipse-temurin-8
+Maven (local) ≥ 3.x (not pinned) System-installed; used for mvn test
+Docker base image (build) maven:3.9.6-eclipse-temurin-8Dockerfile
+Docker base image (runtime) eclipse-temurin:8-jreDockerfile
+Oracle Database Free 23ai (gvenzl/oracle-free:latest) docker-compose.yml
+
+
+
+
Core Business Workflows
+
PhotoAlbum-Java is a personal photo gallery application that lets users upload, browse, view, and delete images stored in a central database.
+
Domain Entities
+
+Entity Service / Bounded Context Description Key Relationships
+
+Photo Photo Management (single bounded context) Represents an uploaded image with its binary content and descriptive metadata (name, size, MIME type, dimensions, upload timestamp) Self-referential temporal ordering: photos are navigated sequentially by uploadedAt timestamp (previous / next)
+
+
Service-to-Domain Mapping
+
+Service Domain Context Owned Entities External Dependencies
+
+photoalbum-java-app Photo Management PhotoOracle Database (persistence of metadata + BLOB image data)
+
+
This is a single-service, single-context application. There are no inter-service dependencies, event buses, or remote API calls to external services.
+
Primary Workflows
+
Workflow 1: Photo Upload
+
A user selects one or more image files in the browser gallery and submits them. The application validates each file, extracts image dimensions, stores the binary content in Oracle, and returns a structured JSON response indicating which uploads succeeded and which failed.
+
Steps:
+
+User submits a multipart POST request with one or more image files.
+Controller iterates over each MultipartFile and calls PhotoService.uploadPhoto().
+Service validates the file's MIME type against the configured allow-list (image/jpeg, image/png, image/gif, image/webp).
+Service validates the file size does not exceed the configured maximum (10 MB).
+Service validates that the file is non-empty (size > 0).
+Service reads all bytes from the multipart stream and attempts to extract pixel dimensions using ImageIO.read().
+Service constructs a Photo entity with a UUID primary key, binary data, metadata, and current timestamp; persists it to Oracle.
+Service returns an UploadResult(success=true, photoId=) to the controller.
+Controller aggregates all individual results into a JSON response: {success, uploadedPhotos[], failedUploads[]}.
+
+
Business rules involved: File type validation, file size limit, empty file rejection, image dimension extraction (best-effort; non-critical failure is tolerated).
+
---
+
Workflow 2: Gallery Browse
+
A user loads the home page to see all uploaded photos in reverse-chronological order.
+
Steps:
+
+User sends a GET request to /.
+Controller calls PhotoService.getAllPhotos().
+Service queries Oracle for all photos ordered by uploadedAt DESC (native SQL).
+Controller passes the photo list to the Thymeleaf index.html template.
+Template renders a thumbnail grid; each thumbnail references /photo/{id} for the image binary.
+
+
---
+
Workflow 3: View Photo Detail with Navigation
+
A user clicks a photo to view it full-size with previous/next navigation.
+
Steps:
+
+User sends a GET request to /detail/{id}.
+Controller calls PhotoService.getPhotoById(id); redirects to / if not found.
+Controller calls PhotoService.getPreviousPhoto(photo) — queries Oracle for the most recent photo uploaded before the current one.
+Controller calls PhotoService.getNextPhoto(photo) — queries Oracle for the oldest photo uploaded after the current one.
+Controller passes photo, previousPhotoId, and nextPhotoId to the detail.html template.
+Template renders the full-size image (referencing /photo/{id}) and navigation arrows.
+
+
---
+
Workflow 4: Serve Photo Binary
+
The browser fetches the actual image bytes for rendering thumbnails and full-size views.
+
Steps:
+
+Browser sends a GET request to /photo/{id} (triggered by an tag).
+Controller calls PhotoService.getPhotoById(id); returns 404 if not found.
+Controller reads the photoData byte array from the Photo entity.
+Controller returns the binary BLOB with the stored MIME type and Cache-Control: no-cache, no-store headers.
+
+
---
+
Workflow 5: Delete Photo
+
A user deletes a photo from the detail page.
+
Steps:
+
+User submits a POST request to /detail/{id}/delete.
+Controller calls PhotoService.deletePhoto(id).
+Service looks up the photo by ID; returns false (not found) if absent.
+Service calls PhotoRepository.delete(photo), removing the row (and BLOB) from Oracle.
+Controller sets a flash attribute (successMessage or errorMessage) and redirects to /.
+
+
Cross-Service Data Flows
+
PhotoAlbum-Java is a monolithic single-service application. All data originates from and returns to a single Oracle database schema. There are no inter-service REST calls, event-driven integrations, or data aggregation across multiple upstream services.
+
The only data composition that occurs is within the detail-view workflow, where the service makes three sequential queries (fetch current photo, fetch previous photo, fetch next photo) and the controller assembles the navigation context before rendering the template. This is intra-service composition, not cross-service.
+
No circuit-breaker fallback paths apply — if Oracle is unavailable, all workflows fail with a runtime exception; there is no degraded-mode behavior implemented.
+
Business Workflow Sequence
+
+sequenceDiagram
+ participant User as "User (Browser)"
+ participant HomeCtrl as "HomeController"
+ participant DetailCtrl as "DetailController"
+ participant FileCtrl as "PhotoFileController"
+ participant PhotoSvc as "PhotoService"
+ participant DB as "Oracle Database"
+
+ Note over User,DB: Workflow 1 - Upload Photo
+ User->>HomeCtrl: POST /upload (image files)
+ loop For each uploaded file
+ HomeCtrl->>PhotoSvc: uploadPhoto(file)
+ PhotoSvc->>PhotoSvc: Validate MIME type
+ alt Invalid MIME type
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, "type not supported")
+ else MIME type OK
+ PhotoSvc->>PhotoSvc: Validate file size <= 10MB
+ alt File too large
+ PhotoSvc-->>HomeCtrl: UploadResult(success=false, "exceeds size limit")
+ else Size OK
+ PhotoSvc->>PhotoSvc: Read bytes, extract dimensions (ImageIO)
+ PhotoSvc->>DB: INSERT Photo (UUID, BLOB, metadata, timestamp)
+ DB-->>PhotoSvc: Saved Photo
+ PhotoSvc-->>HomeCtrl: UploadResult(success=true, photoId)
+ end
+ end
+ end
+ HomeCtrl-->>User: JSON {success, uploadedPhotos[], failedUploads[]}
+
+ Note over User,DB: Workflow 2 - Browse Gallery
+ User->>HomeCtrl: GET /
+ HomeCtrl->>PhotoSvc: getAllPhotos()
+ PhotoSvc->>DB: SELECT all photos ORDER BY uploaded_at DESC
+ DB-->>PhotoSvc: List
+ PhotoSvc-->>HomeCtrl: List
+ HomeCtrl-->>User: HTML gallery page
+
+ Note over User,DB: Workflow 3 - View Photo Detail
+ User->>DetailCtrl: GET /detail/{id}
+ DetailCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>DB: SELECT photo WHERE id = ?
+ DB-->>PhotoSvc: Photo
+ PhotoSvc-->>DetailCtrl: Optional
+ alt Photo not found
+ DetailCtrl-->>User: Redirect to /
+ else Photo found
+ DetailCtrl->>PhotoSvc: getPreviousPhoto(photo)
+ PhotoSvc->>DB: SELECT older photo (UPLOADED_AT < current)
+ DB-->>PhotoSvc: Optional previous Photo
+ DetailCtrl->>PhotoSvc: getNextPhoto(photo)
+ PhotoSvc->>DB: SELECT newer photo (UPLOADED_AT > current)
+ DB-->>PhotoSvc: Optional next Photo
+ DetailCtrl-->>User: HTML detail page with nav arrows
+ User->>FileCtrl: GET /photo/{id} (image src)
+ FileCtrl->>PhotoSvc: getPhotoById(id)
+ PhotoSvc->>DB: SELECT photo_data BLOB WHERE id = ?
+ DB-->>PhotoSvc: Photo with BLOB
+ PhotoSvc-->>FileCtrl: Photo
+ FileCtrl-->>User: Binary image (MIME type, no-cache headers)
+ end
+
+
Business Rules & Decision Logic
+
Validation Rules
+
+Rule Applies To Behavior on Violation
+
+Allowed MIME types: image/jpeg, image/png, image/gif, image/webp Upload Returns UploadResult(success=false) with "File type not supported" message; upload for that file is skipped
+Max file size: 10,485,760 bytes (10 MB) Upload Returns UploadResult(success=false) with "File size exceeds XMB limit" message
+Non-empty file (size > 0) Upload Returns UploadResult(success=false) with "File is empty" message
+Non-null, non-blank photo ID Detail view, file serve, delete Redirects to / (detail/delete) or returns 404 (file serve)
+Maximum files per upload: 10 Upload (multipart config) Enforced at HTTP layer by Spring multipart max-request-size=50MB; no explicit business-layer enforcement of the max-files-per-upload=10 property
+
+
Decision Logic
+
+Batch upload result aggregation : A POST /upload request with multiple files is processed file-by-file. Individual failures do not abort the entire batch; success in the response is true if at least one file uploaded successfully.
+Image dimensions (best-effort) : ImageIO.read() is attempted to extract pixel width/height. If it returns null or throws, the photo is still persisted without dimensions — dimension extraction failure is non-fatal.
+Navigation boundary : getPreviousPhoto and getNextPhoto return Optional.empty() when no older/newer photo exists; the template hides the corresponding navigation arrow.
+
+
State Transitions
+
Photo has a simple two-state lifecycle:
+
+[Uploaded / Persisted] → (user deletes) → [Deleted / Removed]
+
+
There are no intermediate states (draft, pending, approved). Once saved, a photo is immediately visible in the gallery.
+
Transaction Boundaries
+
+PhotoServiceImpl is annotated @Transactional at the class level — all public methods participate in a transaction by default.
+Read operations (getAllPhotos, getPhotoById, getPreviousPhoto, getNextPhoto) are overridden with @Transactional(readOnly = true) to allow Hibernate optimizations.
+Each upload call (uploadPhoto) runs in its own transaction — a failure for one file does not roll back uploads that already completed.
+
+
Error Handling
+
+All service-layer exceptions are caught in the controller and either result in a redirect to / (with flash error message) or a JSON failedUploads entry. No custom business exception types are defined.
+Unexpected exceptions in getAllPhotos cause the gallery to render with an empty list rather than a 500 error page.
+deletePhoto throws RuntimeException on unexpected errors, which surfaces as a 500 if uncaught by the controller.
+
+
Authorization
+
No authentication or authorization is implemented. All workflows are available to any anonymous HTTP client. See api-service-contracts.md for security posture details.
+
Audit / Logging
+
Business operations are logged at DEBUG level (INFO in Docker) using SLF4J. Notable log events: successful upload with photo ID, upload rejection with reason, deletion confirmation. There is no formal audit trail, change-event log, or external audit sink.
+
+
+
Dependency Map
+
PhotoAlbum-Java declares 10 dependencies (8 production + 2 test-scoped) managed via Maven with the Spring Boot 2.7.18 parent BOM.
+
Dependencies
+
+flowchart LR
+ App["PhotoAlbum\nSpring Boot 2.7.18"]
+
+ subgraph BOM["Parent BOM"]
+ ParentBOM["spring-boot-starter-parent\nv2.7.18"]
+ end
+ subgraph Web["Web Frameworks"]
+ SpringWeb["spring-boot-starter-web\n(Spring MVC + Tomcat)"]
+ Thymeleaf["spring-boot-starter-thymeleaf\n(Thymeleaf 3.x)"]
+ end
+ subgraph DB["Database / ORM"]
+ JPA["spring-boot-starter-data-jpa\n(Hibernate 5.6.x)"]
+ OracleJDBC["ojdbc8\n(Oracle JDBC - runtime)"]
+ end
+ subgraph Val["Validation"]
+ Validation["spring-boot-starter-validation\n(Hibernate Validator 6.x)"]
+ end
+ subgraph Util["Utilities"]
+ CommonsIO["commons-io\nv2.11.0"]
+ Jackson["spring-boot-starter-json\n(Jackson 2.14.x)"]
+ DevTools["spring-boot-devtools\n(optional - dev only)"]
+ end
+
+ ParentBOM -.->|"manages versions"| SpringWeb
+ ParentBOM -.->|"manages versions"| Thymeleaf
+ ParentBOM -.->|"manages versions"| JPA
+ ParentBOM -.->|"manages versions"| OracleJDBC
+ ParentBOM -.->|"manages versions"| Validation
+ ParentBOM -.->|"manages versions"| Jackson
+
+ App -->|"BOM"| BOM
+ App -->|"web"| Web
+ App -->|"persistence"| DB
+ App -->|"validation"| Val
+ App -->|"utilities"| Util
+
+
Dependency Summary
+
+Category Count Key Libraries Notes
+
+Web Frameworks 2 Spring MVC (via spring-boot-starter-web), Thymeleaf 3.x Legacy Spring Boot 2.x stack; Spring Boot 2.7.x reaches end-of-life Aug 2023
+Database / ORM 2 Hibernate 5.6.x (via JPA starter), ojdbc8 (Oracle JDBC) Oracle-specific dialect and native queries throughout; tightly coupled to Oracle
+Validation 1 Hibernate Validator 6.x (via validation starter) Jakarta EE 8 / javax.validation namespace — must migrate to jakarta.validation for Spring Boot 3
+Utilities 3 commons-io 2.11.0, Jackson 2.14.x, spring-boot-devtools commons-io 2.11.0 is stable; devtools is optional/dev-only
+
+
Version & Compatibility Risks
+
Spring Boot 2.7.18 is the final 2.x release and reached commercial end-of-life in August 2023 (OSS support ended February 2023). Migrating to Spring Boot 3.x requires upgrading the Java baseline to Java 17 (from the current Java 8) and switching all javax. imports to the jakarta. namespace (EE 9+). Hibernate 5.6.x is also end-of-life; Spring Boot 3 bundles Hibernate 6, which has breaking API changes. The Oracle JDBC driver (ojdbc8) must be kept in sync with the Oracle server version; however the dependency version is managed by the Spring Boot BOM rather than declared explicitly, which may lag behind the latest certified driver. Commons IO 2.11.0 has no known CVEs but a newer 2.15.x line is available.
+
Notable Observations
+
+Java 8 baseline : The application targets Java 8 (maven.compiler.source=8), which is significantly behind the current Java LTS (Java 21). Any migration to Spring Boot 3 mandates a minimum of Java 17; this represents a substantial upgrade effort.
+Oracle vendor lock-in : The use of ojdbc8, Oracle-specific SQL (ROWNUM, TO_CHAR, NVL, RANK() OVER), and OracleDialect makes the data layer non-portable. Migrating to a managed cloud database (e.g., Azure Database for PostgreSQL) would require rewriting all native queries.
+No caching or messaging libraries : The application has no declared caching (e.g., Redis/EhCache) or messaging (e.g., Kafka/Service Bus) dependencies. All photo data is fetched directly from Oracle on every request, which may become a performance bottleneck at scale.
+No security framework : There is no Spring Security or equivalent dependency declared. The application has no authentication or authorization layer, meaning all endpoints are publicly accessible.
+
+
Test Dependencies
+
+Framework Version Notes
+
+spring-boot-starter-test 2.7.18 (managed) Bundles JUnit 5 (Jupiter), Mockito, AssertJ, Spring Test
+H2 Database 2.x (managed by BOM) In-memory database used as Oracle substitute in tests
+
+
Total test-scope dependencies: 2
+
The test setup relies on H2 as a stand-in for Oracle, which may hide Oracle-specific query incompatibilities (native SQL using ROWNUM, TO_CHAR, Oracle analytical functions) during unit testing. No integration testing framework (e.g., Testcontainers with an Oracle image) is present, meaning Oracle-specific code paths are not exercised in CI.
+
+
+
Data Architecture & Persistence Layer
+
PhotoAlbum-Java has a single JPA entity (Photo) persisted in Oracle Database using Hibernate 5.6 via Spring Data JPA, storing image binary content as a BLOB column alongside metadata fields.
+
Database Configuration
+
+Service/Module DB Type Profile Driver Connection Migration Tool
+
+photoalbum-java-app Oracle Database Free (FREEPDB1) default (local) ojdbc8 (Oracle JDBC) jdbc:oracle:thin:@oracle-db:1521/FREEPDB1None — Hibernate ddl-auto=create recreates schema on startup
+photoalbum-java-app Oracle Database Free (XE) docker ojdbc8 (Oracle JDBC) jdbc:oracle:thin:@oracle-db:1521:XENone — Hibernate ddl-auto=create recreates schema on startup
+photoalbum-java-app H2 (in-memory) test H2 JDBC Driver jdbc:h2:mem:testdbNone — Hibernate ddl-auto=create-drop manages test schema
+
+
Schema management is handled entirely by Hibernate DDL auto-generation (create mode in runtime profiles, create-drop for tests). No Flyway or Liquibase migration tool is present; the photos table is dropped and recreated on every application startup, making this configuration unsuitable for production use without persistent data. The Oracle user (photoalbum) is provisioned by the init script oracle-init/01-create-user.sql, which is executed automatically when the Oracle container starts. No seed data files (data.sql, import.sql) are present. For full property key-value details see configuration-inventory.md.
+
Data Ownership per Service
+
+Service Tables Owned ORM Framework Caching Notes
+
+photoalbum-java-app photosHibernate 5.6 via Spring Data JPA None Single table stores all photo metadata and BLOB data; schema auto-generated by Hibernate on startup
+
+
Entity Model
+
+erDiagram
+ PHOTOS {
+ string id PK "UUID (length 36)"
+ string original_file_name "NOT NULL, max 255"
+ blob photo_data "nullable BLOB - binary image content"
+ string stored_file_name "NOT NULL, max 255 - GUID-based filename"
+ string file_path "nullable, max 500 - compatibility only"
+ number file_size "NOT NULL, NUMBER(19,0)"
+ string mime_type "NOT NULL, max 50"
+ timestamp uploaded_at "NOT NULL, DEFAULT SYSTIMESTAMP"
+ number width "nullable - image width in pixels"
+ number height "nullable - image height in pixels"
+ }
+
+
Entity notes:
+
+Photo is annotated @Entity @Table(name = "photos") with a composite index on uploaded_at (idx_photos_uploaded_at).
+The primary key id is a UUID string assigned in the default constructor (UUID.randomUUID().toString()); no @GeneratedValue strategy is used.
+photoData is mapped with @Lob as a byte[], storing the full binary image directly in Oracle.
+@Transactional is applied at the PhotoServiceImpl class level; read-only methods override with @Transactional(readOnly = true).
+
+
Key Repository Methods
+
+Repository Return Type Method Signature Purpose
+
+PhotoRepository ListfindAllOrderByUploadedAtDesc()Lists all photos newest-first for the gallery home page (native Oracle SQL)
+PhotoRepository OptionalfindById(String id) (inherited)Fetches a single photo by UUID for detail view and file serving
+PhotoRepository ListfindPhotosUploadedBefore(LocalDateTime uploadedAt)Returns up to 10 photos older than the given timestamp for backward navigation (uses Oracle ROWNUM)
+PhotoRepository ListfindPhotosUploadedAfter(LocalDateTime uploadedAt)Returns photos newer than the given timestamp for forward navigation
+PhotoRepository ListfindPhotosByUploadMonth(String year, String month)Filters photos by year/month using Oracle TO_CHAR — declared but not currently called from any controller
+PhotoRepository ListfindPhotosWithPagination(int startRow, int endRow)Oracle ROWNUM-based pagination — declared but not currently called from any controller
+PhotoRepository ListfindPhotosWithStatistics()Returns photos with RANK() OVER and running SUM analytical functions — declared but not currently called
+PhotoRepository voiddelete(Photo) (inherited)Removes a photo record (and its BLOB) from the database
+PhotoRepository Photosave(Photo) (inherited)Inserts or updates a photo record
+
+
All custom methods use @Query(nativeQuery = true) with Oracle-specific SQL. Source file: src/main/java/com/photoalbum/repository/PhotoRepository.java.
+
Caching Strategy
+
No caching layer is configured. There are no @Cacheable, @CacheEvict, or @CachePut annotations, no cache provider (EhCache, Redis, Caffeine) on the classpath, and no Hibernate second-level cache configuration. Every HTTP request to the gallery home page (GET /) triggers a full SELECT ... FROM PHOTOS ORDER BY UPLOADED_AT DESC query against Oracle, and every GET /photo/{id} retrieves the full BLOB from the database. The PhotoFileController sets aggressive Cache-Control: no-cache, no-store response headers, which also prevents browser-side caching of image binaries.
+
Data Ownership Boundaries
+
Single-service, single-database topology. There is only one deployable application service and one database. All data is owned by photoalbum-java-app in a single Oracle schema (photoalbum user). There are no cross-service data access patterns, shared databases, or inter-service queries.
+
Read/write pattern: All reads and writes go through PhotoRepository (Spring Data JPA backed by Hibernate). There is no CQRS separation, read replica, or event sourcing; the same Oracle instance handles both queries and mutations.
+
Schema management risk: Hibernate ddl-auto=create drops and recreates the photos table (including all BLOB data) on every application restart in both the default and Docker profiles. This is a critical data-loss risk in any environment where photos are expected to persist across restarts.
+
Data Classification & Sensitivity
+
+Entity Sensitive Fields Classification Controls in Place
+
+Photo original_file_name (user-provided filename), photo_data (image BLOB — may contain EXIF metadata including GPS coordinates, device identifiers)Potentially PII (EXIF data embedded in images) None — no encryption-at-rest, no EXIF stripping, no field-level access control, no masking
+
+
The photos table does not store explicit PII such as usernames, email addresses, or personal identifiers. However, uploaded image files may contain EXIF metadata (GPS location, device serial number, timestamps) embedded in the binary data, which constitutes PII under GDPR. The application reads image bytes directly via ImageIO to extract pixel dimensions but does not strip EXIF data before persisting the BLOB. No encryption-at-rest is configured for the Oracle tablespace, and there is no authentication layer protecting access to stored images.
+
+
+
+
+
\ No newline at end of file
diff --git a/.github/modernize/assessment/reports/report-20260521055101/report.json b/.github/modernize/assessment/reports/report-20260521055101/report.json
new file mode 100644
index 000000000..b4f7b1d55
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/report.json
@@ -0,0 +1,1021 @@
+{
+ "version": "1.0.0",
+ "producer": "Java AppCAT CLI",
+ "metadata": {
+ "analysisStartTime": "2026-05-21T05:51:01.828110938Z",
+ "analysisEndTime": "2026-05-21T05:51:43.42647934Z",
+ "status": "Complete",
+ "privacyMode": "Protected",
+ "privacyModeHelpUrl": "https://aka.ms/appcat-privacy-mode",
+ "targetIds": [
+ "azure-aks",
+ "azure-appservice",
+ "azure-container-apps"
+ ],
+ "targetDisplayNames": [
+ "Azure Kubernetes Service",
+ "Azure App Service",
+ "Azure Container Apps"
+ ],
+ "capabilities": [],
+ "os": []
+ },
+ "summary": {
+ "totalProjects": 1,
+ "totalIssues": 9,
+ "totalIncidents": 27,
+ "totalEffort": 172,
+ "charts": {
+ "severity": {
+ "mandatory": 11,
+ "optional": 2,
+ "potential": 14,
+ "information": 0
+ },
+ "category": {
+ "database-migration": 6,
+ "framework-upgrade": 10,
+ "jakarta-migration": 1,
+ "java-version-upgrade": 3,
+ "local-credential": 3,
+ "spring-migration": 4
+ }
+ }
+ },
+ "projects": [
+ {
+ "path": ".",
+ "issues": 9,
+ "storyPoints": 172,
+ "properties": {
+ "appName": "photo-album",
+ "jdkVersion": "1.8",
+ "frameworks": [
+ "Spring Boot",
+ "Spring"
+ ],
+ "languages": [
+ "Java",
+ "Python"
+ ],
+ "tools": [
+ "Maven"
+ ]
+ },
+ "incidents": [
+ {
+ "ruleId": "spring-framework-version-01000",
+ "incidentId": "83e5b505-a412-4113-a040-6f1ddf7e2770",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 46,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-framework-version-01000",
+ "incidentId": "8bb7d400-9848-4005-a82b-b722ed261538",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 34,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-framework-version-01000",
+ "incidentId": "8b96a396-918d-4fb1-af14-dd36041e6ddc",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 78,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-java-version-01000",
+ "incidentId": "54cf6fd8-f236-4129-8121-f621915d79f2",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 24,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-java-version-02000",
+ "incidentId": "af7c9f6d-2db3-4ea9-a106-c8c2ab9ffad8",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 25,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "optional"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "optional"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "optional"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-java-version-02000",
+ "incidentId": "34f66cdb-1ec6-435a-9395-415fb8c8f9f6",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 26,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "optional"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "optional"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "optional"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-port-01000",
+ "incidentId": "2d62fd2b-409a-4048-814f-b8e36bc95c65",
+ "location": "src/main/resources/application-docker.properties",
+ "locationKind": "File",
+ "line": 24,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 1,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 1,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 1,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-port-01000",
+ "incidentId": "8e95025c-c2c0-4b24-95f2-5985622941a2",
+ "location": "src/main/resources/application.properties",
+ "locationKind": "File",
+ "line": 2,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 1,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 1,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 1,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-restricted-config-01000",
+ "incidentId": "4eb46111-ce35-4b59-8afb-71ee0038f0e8",
+ "location": "src/main/resources/application-docker.properties",
+ "locationKind": "File",
+ "line": 24,
+ "column": 0,
+ "targets": {
+ "azure-container-apps": {
+ "effort": 2,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-restricted-config-01000",
+ "incidentId": "8452c91f-71ce-4459-8510-eb418dcd1e27",
+ "location": "src/main/resources/application.properties",
+ "locationKind": "File",
+ "line": 2,
+ "column": 0,
+ "targets": {
+ "azure-container-apps": {
+ "effort": 2,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-spring-boot-version-01000",
+ "incidentId": "357063d1-f8a5-4666-89ab-6e2c8616ec3a",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 59,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-spring-boot-version-01000",
+ "incidentId": "dc5e9ea2-4fd6-4bf1-8e38-38247577d8d4",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 72,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-spring-boot-version-01000",
+ "incidentId": "193365aa-deaa-48b3-89ec-07bb205f8f02",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 46,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-spring-boot-version-01000",
+ "incidentId": "6051db9b-ed5a-409e-84a9-7d85621b69af",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 34,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-spring-boot-version-01000",
+ "incidentId": "3f339d21-8f08-4589-a68b-1cb6d2bcaf0f",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 78,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-spring-boot-version-01000",
+ "incidentId": "4923dbfc-228a-48d2-8b38-47058cb104c3",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 92,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "spring-boot-to-azure-spring-boot-version-01000",
+ "incidentId": "657cb376-164e-4ca4-9a1e-0b111387b71e",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 40,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "mandatory"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "mandatory"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-database-microsoft-oracle-07000",
+ "incidentId": "313960ee-95df-4aa4-8c89-c2d4264f2eee",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 52,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-database-microsoft-oracle-07000",
+ "incidentId": "1a9e2314-3140-452a-92fd-5ef632c31b56",
+ "location": "src/main/resources/application.properties",
+ "locationKind": "File",
+ "line": 10,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-database-microsoft-oracle-07000",
+ "incidentId": "76a67adf-cca5-44b4-bfdf-730a02912bb7",
+ "location": "docker-compose.yml",
+ "locationKind": "File",
+ "line": 32,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-database-microsoft-oracle-07000",
+ "incidentId": "d4bc4ffd-5b7e-473c-a55d-92d5d3203b03",
+ "location": "src/main/resources/application-docker.properties",
+ "locationKind": "File",
+ "line": 2,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-database-microsoft-oracle-07000",
+ "incidentId": "cf569c79-39ac-45a3-816e-ee2b538d3ef6",
+ "location": "src/main/resources/application-docker.properties",
+ "locationKind": "File",
+ "line": 5,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-database-microsoft-oracle-07000",
+ "incidentId": "9e2cd362-c140-4070-857a-b4cf7ce40c51",
+ "location": "src/main/resources/application.properties",
+ "locationKind": "File",
+ "line": 13,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 8,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 8,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-password-01000",
+ "incidentId": "02e3a7e6-4b82-4da3-a3a2-451acaa5478f",
+ "location": "src/test/resources/application-test.properties",
+ "locationKind": "File",
+ "line": 5,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 3,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 3,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 3,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-password-01000",
+ "incidentId": "13a0abbe-69b7-450d-95d7-be5747798dfa",
+ "location": "src/main/resources/application-docker.properties",
+ "locationKind": "File",
+ "line": 4,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 3,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 3,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 3,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "azure-password-01000",
+ "incidentId": "5ae96ba5-5f02-4ff7-8515-2f51dcab8476",
+ "location": "src/main/resources/application.properties",
+ "locationKind": "File",
+ "line": 12,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 3,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 3,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 3,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=azure/springboot"
+ ]
+ },
+ {
+ "ruleId": "jakarta-database-00002",
+ "incidentId": "5621a50e-669b-42f5-b69b-f9ec94a1827c",
+ "location": "pom.xml",
+ "locationKind": "File",
+ "line": 46,
+ "column": 0,
+ "targets": {
+ "azure-aks": {
+ "effort": 5,
+ "severity": "potential"
+ },
+ "azure-appservice": {
+ "effort": 5,
+ "severity": "potential"
+ },
+ "azure-container-apps": {
+ "effort": 5,
+ "severity": "potential"
+ }
+ },
+ "labels": [
+ "type=violation",
+ "ruleset=cloud-readiness"
+ ]
+ }
+ ]
+ }
+ ],
+ "rules": {
+ "azure-database-microsoft-oracle-07000": {
+ "id": "azure-database-microsoft-oracle-07000",
+ "description": "Oracle database found. To migrate a Java application that uses an Oracle database to Azure, you can follow these recommendations:\n\n * **Migrate to Azure Database for PostgreSQL**: Azure recommends migrating Oracle databases to Azure Database for PostgreSQL Flexible Server as it provides better cost-effectiveness and performance. Create a managed PostgreSQL Flexible Server database in Azure and choose the appropriate pricing tier based on your application's requirements.\n\n * **Use migration tools**: Utilize the Azure Database Migration Service (DMS) or third-party tools to migrate your Oracle database schema and data to PostgreSQL. Consider using ora2pg or similar tools to convert Oracle-specific SQL to PostgreSQL-compatible SQL.\n\n * **Update database drivers and connection strings**: Replace Oracle JDBC drivers with PostgreSQL drivers in your Java application. Update connection strings from Oracle format (jdbc:oracle:thin:) to PostgreSQL format (jdbc:postgresql:).\n\n * **Review and convert Oracle-specific code**: Identify and convert Oracle-specific SQL functions, stored procedures, and PL/SQL code to PostgreSQL equivalents. Pay attention to data types, syntax differences, and built-in functions.\n\n * Enable **monitoring and diagnostics**: Utilize Azure Monitor to gain insights into the performance and health of your Java application and the underlying PostgreSQL database. Set up metrics, alerts, and log analytics to proactively identify and resolve issues.\n\n * Implement **security** measures: Apply security best practices to protect your Java application and the PostgreSQL database. This includes implementing authentication and authorization mechanisms with passwordless connections and leveraging Microsoft Defender for Cloud for threat detection and vulnerability assessments.\n\n * **Backup** your data: Azure Database for PostgreSQL provides automated backups by default. You can configure the retention period for backups based on your requirements. You can also enable geo-redundant backups, if needed, to enhance data durability and availability.",
+ "title": "Oracle database found",
+ "severity": "potential",
+ "effort": 8,
+ "links": [
+ {
+ "url": "https://learn.microsoft.com/azure/postgresql",
+ "title": "Azure Database for PostgreSQL documentation"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/postgresql/migrate/how-to-migrate-oracle-ora2pg",
+ "title": "Oracle to PostgreSQL migration guide"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/dms",
+ "title": "Azure Database Migration Service documentation"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/azure-monitor",
+ "title": "Azure Monitor documentation"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/defender-for-cloud",
+ "title": "Microsoft Defender for Cloud"
+ }
+ ],
+ "labels": [
+ "target=azure-appservice",
+ "target=azure-aks",
+ "target=azure-container-apps",
+ "source",
+ "domain=cloud-readiness",
+ "category=database-migration",
+ "database",
+ "oracle",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "azure-java-version-01000": {
+ "id": "azure-java-version-01000",
+ "description": "The application is using a Java version that has reached the end of support. It is strongly recommended to plan and execute a migration strategy to upgrade your application to a supported Java version.\nSupported Java versions receive long-term support (LTS) from the Java community, including bug fixes and updates. Migrating to a supported version provides you with a stable and well-maintained platform for your application.",
+ "title": "Java Version Has Reached the End of Support",
+ "severity": "mandatory",
+ "effort": 8,
+ "labels": [
+ "target=azure-appservice",
+ "target=azure-aks",
+ "target=azure-container-apps",
+ "source",
+ "domain=java-upgrade",
+ "category=java-version-upgrade",
+ "version",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "azure-java-version-02000": {
+ "id": "azure-java-version-02000",
+ "description": "The application is not using the latest LTS Java version. It is recommended to consider upgrading to the latest LTS version to take advantage of the newest language features, performance improvements, and extended support timelines.\nUpgrading to the latest LTS version ensures your application benefits from the most recent security enhancements and a longer support lifecycle.",
+ "title": "Java Version is not the latest LTS",
+ "severity": "optional",
+ "effort": 8,
+ "labels": [
+ "target=azure-appservice",
+ "target=azure-aks",
+ "target=azure-container-apps",
+ "source",
+ "domain=java-upgrade",
+ "category=java-version-upgrade",
+ "version",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "azure-password-01000": {
+ "id": "azure-password-01000",
+ "description": "Using clear passwords in property files is a security risk, as they can be easily compromised if the files are accessed by unauthorized individuals. To enhance the security of your application, it is recommended to employ secure credential management practices.\n\n * **Azure Key Vault**: Utilize Azure Key Vault to securely store and manage your application's passwords and other sensitive credentials. Azure Key Vault provides a centralized and highly secure location for storing secrets, keys, and certificates.\n\n * **Passwordless connections**: You can provide an additional layer of security and convenience for accessing resources in Azure by eliminating the need for passwords. This way you can reduce the risk of password-related vulnerabilities, such as weak passwords or password theft.",
+ "title": "Password found in configuration file",
+ "severity": "potential",
+ "effort": 3,
+ "links": [
+ {
+ "url": "https://learn.microsoft.com/azure/key-vault",
+ "title": "Azure Key Vault documentation"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/developer/intro/passwordless-overview",
+ "title": "Passwordless connections for Azure services"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/developer/java/migration/migrate-spring-boot-to-azure-container-apps#inventory-configuration-sources-and-secrets",
+ "title": "Password found in configuration file"
+ },
+ {
+ "url": "https://docs.microsoft.com/azure/developer/java/spring-framework/configure-spring-boot-starter-java-app-with-azure-key-vault",
+ "title": "Read a secret from Azure Key Vault in a Spring Boot application"
+ },
+ {
+ "url": "https://search.maven.org/artifact/com.azure.spring/azure-spring-boot-starter-keyvault-secrets",
+ "title": "Azure Spring Boot Starter for Azure Key Vault Secrets"
+ }
+ ],
+ "labels": [
+ "source",
+ "target=azure-appservice",
+ "target=azure-aks",
+ "target=azure-container-apps",
+ "domain=cloud-readiness",
+ "category=local-credential",
+ "password",
+ "security",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "jakarta-database-00002": {
+ "id": "jakarta-database-00002",
+ "description": "The application depends on **Jakarta Persistence (JPA)** APIs (`jakarta.persistence.*` or legacy `javax.persistence.*`), which are used for object-relational mapping (ORM) and database interaction in Jakarta EE or Java EE applications.\n\nWhen migrating to Azure:\n- Ensure that the database connection, JPA provider (e.g., Hibernate, EclipseLink), and dialect are compatible with your target Azure database service.\n- Recommended database services include **Azure Database for PostgreSQL**, **Azure Database for MySQL**, or **Azure SQL Database**.\n- For containerized deployments, these JPA-based applications can run on **Azure Kubernetes Service (AKS)** or **Azure App Service for Linux/Windows**.\n- If using **Spring Data JPA**, verify that connection pool settings and environment variables are properly configured for the cloud environment.\n- Consider leveraging **Azure Key Vault** for secure storage of database credentials and connection strings.",
+ "title": "Detects usage of Jakarta Persistence (JPA) APIs",
+ "severity": "potential",
+ "effort": 5,
+ "links": [
+ {
+ "url": "https://jakarta.ee/specifications/persistence/",
+ "title": "Jakarta Persistence Specification"
+ },
+ {
+ "url": "https://learn.microsoft.com/en-us/azure/architecture/guide/technology-choices/data-stores-getting-started#common-database-scenarios",
+ "title": "Prepare to choose a data store in Azure"
+ }
+ ],
+ "labels": [
+ "source=java",
+ "source=java-ee",
+ "target=azure-aks",
+ "target=azure-container-apps",
+ "target=azure-appservice",
+ "domain=cloud-readiness",
+ "category=jakarta-migration",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "spring-boot-to-azure-port-01000": {
+ "id": "spring-boot-to-azure-port-01000",
+ "description": "The application is setting the server port. To migrate a Java application that sets the server port to Azure Container Apps:\n\n * **Azure Container Apps allows you to expose port according to your Azure Container Apps resource configuration. For instance, a Spring Boot application listens to port of 8080 by default, but it can be set with server.port or environment variable SERVER_PORT as you need.",
+ "title": "Server port configuration found",
+ "severity": "potential",
+ "effort": 1,
+ "links": [
+ {
+ "url": "https://learn.microsoft.com/azure/developer/java/migration/migrate-spring-boot-to-azure-container-apps#identify-any-clients-relying-on-a-non-standard-port",
+ "title": "Identify any clients relying on a non-standard port"
+ }
+ ],
+ "labels": [
+ "source=springboot",
+ "target=azure-aks",
+ "target=azure-appservice",
+ "target=azure-container-apps",
+ "domain=cloud-readiness",
+ "category=spring-migration",
+ "port",
+ "server port",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "spring-boot-to-azure-restricted-config-01000": {
+ "id": "spring-boot-to-azure-restricted-config-01000",
+ "description": "The application uses restricted configurations for Azure Container Apps.\n These properties can be automatically injected into your application environment by Azure Container Apps to access managed Config Server and managed Eureka Server.\n Please remove them from your application, including configuration files, config server files, command line parameters, Java system attributes, and environment variables.\n\n If configured in **configuration files**: they will be ignored and overrided by Azure Container Apps.\n \n If configured in **Config Server files**, **command line parameters**, **Java system attribute**, **environment variable**: they need to be removed or you might experience conflicts and unexpected behavior.",
+ "title": "Restricted configurations found",
+ "severity": "potential",
+ "effort": 2,
+ "links": [
+ {
+ "url": "https://learn.microsoft.com/azure/developer/java/migration/migrate-spring-cloud-to-azure-container-apps#remove-restricted-configurations",
+ "title": "Migrate Spring Boot applications to Azure Container Apps - Remove restricted configurations"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/container-apps/java-config-server?tabs=azure-cli",
+ "title": "Connect to a managed Config Server for Spring in Azure Container Apps"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/container-apps/java-eureka-server?tabs=azure-cli",
+ "title": "Connect to a managed Eureka Server for Spring in Azure Container Apps"
+ }
+ ],
+ "labels": [
+ "target=azure-container-apps",
+ "source=springboot",
+ "domain=cloud-readiness",
+ "category=spring-migration",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "spring-boot-to-azure-spring-boot-version-01000": {
+ "id": "spring-boot-to-azure-spring-boot-version-01000",
+ "description": "The application is using a Spring Boot version that has reached its End of OSS Support.\nWith the officially supported new versions from Spring, you can get the best experience. Here are some steps you can take to update your application to the latest version of Spring Boot:\n\n* Choose a **supported Spring Boot version**: Check out Spring Boot Support Versions and determine the most suitable supported Spring Boot version.\n\n* **Update Spring Boot version**: Update the Spring Boot version of your application. There are automated tools like Rewrite to help you with the migration.\n\n* **Address code compatibility**: Review your application's codebase for any potential compatibility issues with the target Spring Boot version. Update deprecated APIs or features, address any language or library changes, and ensure that your code follows best practices and standards.\n\n* **Test thoroughly**: Execute a comprehensive testing process to verify the compatibility and functionality of your application with the new Spring Boot version. Perform unit tests, integration tests, and system tests to validate that all components and dependencies work as expected.",
+ "title": "Spring Boot Version Has Reached the End of OSS Support",
+ "severity": "mandatory",
+ "effort": 8,
+ "links": [
+ {
+ "url": "https://learn.microsoft.com/azure/developer/java/migration/migrate-spring-boot-to-azure-container-apps",
+ "title": "Migrate Spring Boot applications to Azure Container Apps"
+ },
+ {
+ "url": "https://learn.microsoft.com/azure/container-apps/java-microservice-get-started?tabs=azure-cli",
+ "title": "Launch your first Java microservice application with managed Java components in Azure Container Apps"
+ },
+ {
+ "url": "https://spring.io/projects/spring-boot/#support",
+ "title": "Spring Boot Supported Versions"
+ },
+ {
+ "url": "https://github.com/spring-projects/spring-boot/wiki/Supported-Versions",
+ "title": "Spring Boot Support Policy"
+ }
+ ],
+ "labels": [
+ "source=springboot",
+ "target=azure-appservice",
+ "target=azure-aks",
+ "target=azure-container-apps",
+ "domain=java-upgrade",
+ "category=framework-upgrade",
+ "version",
+ "os=windows",
+ "os=linux"
+ ]
+ },
+ "spring-framework-version-01000": {
+ "id": "spring-framework-version-01000",
+ "description": "Your application is using a Spring Framework version that has reached its End of OSS Support.\nUpgrading to a supported version ensures better performance, security, and compatibility with modern tools.\n 1. Pick a Supported Version: Review the Spring Framework support policy and choose an actively supported version.\n 2. Update Your Project: Change the Spring Framework version in your pom.xml or build.gradle.\n 3. Fix Compatibility Issues: Update deprecated code, replace removed features, and ensure dependencies are compatible with the new Spring Framework version.\n 4. Thoroughly Test: Run unit, integration, and end-to-end tests to make sure everything still works after the upgrade.",
+ "title": "Spring Framework Version Has Reached the End of OSS Support",
+ "severity": "mandatory",
+ "effort": 8,
+ "links": [
+ {
+ "url": "https://spring.io/projects/spring-framework#support",
+ "title": "Spring Framework Supported Versions"
+ },
+ {
+ "url": "https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-Versions",
+ "title": "Spring Framework Support Policy"
+ }
+ ],
+ "labels": [
+ "source=spring",
+ "target=azure-appservice",
+ "target=azure-aks",
+ "target=azure-container-apps",
+ "domain=java-upgrade",
+ "category=framework-upgrade",
+ "version",
+ "os=windows",
+ "os=linux"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.github/modernize/assessment/reports/report-20260521055101/report.md b/.github/modernize/assessment/reports/report-20260521055101/report.md
new file mode 100644
index 000000000..a20ab2dd5
--- /dev/null
+++ b/.github/modernize/assessment/reports/report-20260521055101/report.md
@@ -0,0 +1,137 @@
+# photo-album
+
+## Summary
+
+| Metric | Value |
+|--------|-------|
+| Total Issues | 9 |
+| Mandatory Blockers | 3 |
+| Potential Issues | 5 |
+
+## Application Information
+
+| Property | Value |
+|----------|-------|
+| Language | Java, Python |
+| Frameworks | Spring Boot, Spring |
+| Build tools | Maven |
+| JDK version | 1.8 |
+
+## Cloud Readiness Issues
+
+| Issue Name | Criticality | Story Points | Occurrences |
+|------------|-------------|--------------|-------------|
+| Oracle database found | Potential | 8 | [6](#Oracle_database_found) |
+| Password found in configuration file | Potential | 3 | [3](#Password_found_in_configuration_file) |
+| Server port configuration found | Potential | 1 | [2](#Server_port_configuration_found) |
+| Restricted configurations found | Potential | 2 | [2](#Restricted_configurations_found) |
+| Detects usage of Jakarta Persistence (JPA) APIs | Potential | 5 | [1](#Detects_usage_of_Jakarta_Persistence_JPA_APIs) |
+
+### Issue Details
+
+
+Oracle database found — affected files
+
+- `pom.xml (line 52)`
+- `src/main/resources/application.properties (line 10)`
+- `docker-compose.yml (line 32)`
+- `src/main/resources/application-docker.properties (line 2)`
+- `src/main/resources/application-docker.properties (line 5)`
+- `src/main/resources/application.properties (line 13)`
+
+
+
+
+Password found in configuration file — affected files
+
+- `src/test/resources/application-test.properties (line 5)`
+- `src/main/resources/application-docker.properties (line 4)`
+- `src/main/resources/application.properties (line 12)`
+
+
+
+
+Server port configuration found — affected files
+
+- `src/main/resources/application-docker.properties (line 24)`
+- `src/main/resources/application.properties (line 2)`
+
+
+
+
+Restricted configurations found — affected files
+
+- `src/main/resources/application-docker.properties (line 24)`
+- `src/main/resources/application.properties (line 2)`
+
+
+
+
+Detects usage of Jakarta Persistence (JPA) APIs — affected files
+
+- `pom.xml (line 46)`
+
+
+
+## Upgrade Issues
+
+| Issue Name | Criticality | Story Points | Occurrences |
+|------------|-------------|--------------|-------------|
+| Spring Boot Version Has Reached the End of OSS Support | Mandatory | 8 | [7](#Spring_Boot_Version_Has_Reached_the_End_of_OSS_Support) |
+| Spring Framework Version Has Reached the End of OSS Support | Mandatory | 8 | [3](#Spring_Framework_Version_Has_Reached_the_End_of_OSS_Support) |
+| Java Version Has Reached the End of Support | Mandatory | 8 | [1](#Java_Version_Has_Reached_the_End_of_Support) |
+| Java Version is not the latest LTS | Optional | 8 | [2](#Java_Version_is_not_the_latest_LTS) |
+
+### Issue Details
+
+
+Spring Boot Version Has Reached the End of OSS Support — affected files
+
+- `pom.xml (line 59)`
+- `pom.xml (line 72)`
+- `pom.xml (line 46)`
+- `pom.xml (line 34)`
+- `pom.xml (line 78)`
+- `pom.xml (line 92)`
+- `pom.xml (line 40)`
+
+
+
+
+Spring Framework Version Has Reached the End of OSS Support — affected files
+
+- `pom.xml (line 46)`
+- `pom.xml (line 34)`
+- `pom.xml (line 78)`
+
+
+
+
+Java Version Has Reached the End of Support — affected files
+
+- `pom.xml (line 24)`
+
+
+
+
+Java Version is not the latest LTS — affected files
+
+- `pom.xml (line 25)`
+- `pom.xml (line 26)`
+
+
+
+---
+
+## Codebase Insights
+
+> **Note:** These documents are generated by AI and may contain inaccuracies or incomplete information. Please review carefully.
+
+1. **[Architecture Diagram](facts/architecture-diagram.md)** — Understand the big picture: system layers and component relationships
+2. **[Dependency Map](facts/dependency-map.md)** — Know what the project depends on and where the risks are
+3. **[API & Service Contracts](facts/api-service-contracts.md)** — See how services communicate and what contracts they expose
+4. **[Data Architecture](facts/data-architecture.md)** — Explore data models, storage, and data flow patterns
+5. **[Configuration Inventory](facts/configuration-inventory.md)** — Review how the application is configured across environments
+6. **[Business Workflows](facts/business-workflows.md)** — Trace end-to-end business processes and domain logic
+
+[Share feedback](https://aka.ms/ghcp-appmod/feedback)