SentinelX is a backend system for insider threat monitoring. It tracks user activity, evaluates behavioral risk, and creates actionable alerts so security teams can detect abnormal behavior early.
- Java 17
- Spring Boot
- Spring Security
- JWT
- Flyway
- PostgreSQL
- Maven
SentinelX is a REST API backend with role-based access control and JWT authentication. The codebase is organized into modular domains (auth, users, activity, risk, alerts, dashboard, common) and each module owns its controller, service, repository, DTO, and entity responsibilities.
- Java 17
- Maven
- PostgreSQL 15 or newer
- Docker (optional but recommended for local database)
- Clone the repository.
git clone https://github.com/KoroS11/Sentinel-X.git
cd Sentinel-X- Copy secrets.local.example.properties to secrets.local.properties and fill all keys.
cd backend
copy secrets.local.example.properties secrets.local.propertiesRequired keys in secrets.local.properties:
- DB_URL: JDBC URL used by Spring datasource in dev and test profiles. Example: jdbc:postgresql://localhost:5432/sentinelx
- DB_USERNAME: database username used for datasource authentication.
- DB_PASSWORD: database password used for datasource authentication.
- SERVER_PORT: backend port. Set to 8081 to match the frontend integration guide in this document.
- JWT_SECRET: HMAC signing secret for access tokens. Must be at least 32 characters.
- JWT_EXPIRATION_MS: access token lifetime in milliseconds.
- JWT_REFRESH_EXPIRATION_MS: refresh token lifetime in milliseconds.
Example complete file:
DB_URL=jdbc:postgresql://localhost:5432/sentinelx
DB_USERNAME=sentinelx
DB_PASSWORD=sentinelx
SERVER_PORT=8081
JWT_SECRET=sentinelx_local_dev_secret_key_minimum_32_chars
JWT_EXPIRATION_MS=3600000
JWT_REFRESH_EXPIRATION_MS=604800000- Start PostgreSQL.
Exact local Docker command used for this project setup:
docker run --name sentinelx-postgres -e POSTGRES_DB=sentinelx -e POSTGRES_USER=sentinelx -e POSTGRES_PASSWORD=sentinelx -p 5432:5432 -d postgres:15- Run the backend with dev profile.
./mvnw.cmd spring-boot:run -Dspring-boot.run.profiles=dev- Verify startup with health endpoint.
curl http://localhost:8081/healthExpected response when app and DB are healthy:
{
"status": "UP",
"application": "UP",
"database": "CONNECTED",
"timestamp": "2026-04-05T12:00:00Z"
}If DB is unavailable, status code is 503 and response status is DEGRADED.
application.properties is the entrypoint configuration. It sets defaults and activates profile from SPRING_PROFILES_ACTIVE (default dev).
application-dev.properties contains local development datasource and JWT bindings. It imports secrets.local.properties using optional import.
application-test.properties contains test datasource and JPA validation settings. Tests provide isolated datasource values at runtime.
application-prod.properties contains production datasource and JPA settings; values are resolved from environment variables.
| Key | Purpose | Profile(s) | Required |
|---|---|---|---|
| SPRING_PROFILES_ACTIVE | Selects active Spring profile (default dev) | Global | Optional |
| SERVER_PORT | HTTP server port | Global | Optional (default 8080) |
| DB_URL | JDBC datasource URL | dev, test, prod | Required |
| DB_USERNAME | JDBC datasource username | dev, test, prod | Required |
| DB_PASSWORD | JDBC datasource password | dev, test, prod | Required |
| JWT_SECRET | JWT signing secret (minimum 32 chars) | dev, test, prod | Required |
| JWT_EXPIRATION_MS | Access token expiration in milliseconds | dev, test, prod | Required |
| JWT_REFRESH_EXPIRATION_MS | Refresh token expiration in milliseconds | dev, test, prod | Required |
secrets.local.properties is a local developer secrets file loaded only in dev through spring.config.import=optional:file:./secrets.local.properties.
It is gitignored because it contains machine-specific credentials and secrets.
It should contain these keys: DB_URL, DB_USERNAME, DB_PASSWORD, SERVER_PORT, JWT_SECRET, JWT_EXPIRATION_MS, JWT_REFRESH_EXPIRATION_MS.
| Migration | Table/Change | Purpose | Key Columns | Foreign Keys |
|---|---|---|---|---|
| V1__init.sql | roles | Role catalog | id, name | none |
| V1__init.sql | users | User accounts and identity | id, username, email, password_hash, is_active, role_id, created_at, updated_at | users.role_id -> roles.id |
| V2__add_refresh_tokens.sql | refresh_tokens | Refresh token storage and revocation | id, token, user_id, expiry_date, revoked, created_at | refresh_tokens.user_id -> users.id |
| V3__add_password_reset_tokens.sql | password_reset_tokens | Password reset flow tokens | id, token, user_id, expiry_date, used, created_at | password_reset_tokens.user_id -> users.id |
| V4__add_email_verification.sql | users alter | Adds email_verified flag | email_verified | n/a |
| V4__add_email_verification.sql | email_verification_tokens | Email verification flow tokens | id, token, user_id, expiry_date, used | email_verification_tokens.user_id -> users.id |
| V5__add_activity_table.sql | activities | Audit activity stream | id, user_id, action, entity_type, entity_id, metadata, created_at | activities.user_id -> users.id |
| V6__add_risk_scores_table.sql | risk_scores | Historical risk scoring records | id, user_id, score, reason, calculated_at | risk_scores.user_id -> users.id |
| V7__add_alerts_table.sql | alerts | Alert lifecycle tracking | id, user_id, risk_score_id, severity, message, status, created_at, updated_at | alerts.user_id -> users.id; alerts.risk_score_id -> risk_scores.id |
| V8__add_user_status.sql | users alter | Adds account status | status | n/a |
| V9__add_alert_assignee.sql | alerts alter | Adds analyst assignee relation | assigned_to_user_id, updated_at | alerts.assigned_to_user_id -> users.id |
roles (id, name)
users (role_id FK)
users (id, username, email, password_hash, is_active, email_verified, status, created_at, updated_at)
refresh_tokens (user_id FK)
password_reset_tokens (user_id FK)
email_verification_tokens (user_id FK)
activities (user_id FK)
risk_scores (user_id FK)
alerts (user_id FK)
assigned_to_user_id FK -> users.id
risk_score_id FK -> risk_scores.id
Flyway is the authoritative mechanism for all schema changes.
Migration naming convention is V{number}__{description}.sql.
JPA is configured with spring.jpa.hibernate.ddl-auto=validate, so Hibernate validates schema compatibility and never applies schema mutations.
Never modify an already-applied migration file. Create a new versioned migration for every schema change.
SeedDataRunner calls RoleService.ensureDefaultRoles() on startup.
Seeded roles are:
- ADMIN
- ANALYST
- EMPLOYEE
Seeding is idempotent because roles are created only if missing.
Common rules:
- Base API path uses /api for business modules.
- Protected endpoints require Authorization header: Bearer access token.
- Content-Type for request bodies is application/json.
Common error shape from GlobalExceptionHandler:
{
"timestamp": "2026-04-05T12:00:00Z",
"status": 400,
"error": "Validation failed"
}| Property | Value |
|---|---|
| Auth required | No |
| Required role | Public |
Request body:
{
"username": "string, required, not blank",
"email": "string, required, valid email",
"password": "string, required, not blank"
}Success response (200):
{
"token": "jwt-access-token",
"username": "alice",
"refreshToken": null
}Error responses:
- 400 validation failure
- 409 email already registered
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | No |
| Required role | Public |
Request body:
{
"email": "string, required, valid email",
"password": "string, required, not blank"
}Success response (200):
{
"token": "jwt-access-token",
"username": "alice",
"refreshToken": "uuid-refresh-token"
}Error responses:
- 400 validation failure
- 401 invalid credentials
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | No |
| Required role | Public |
Request body:
{
"refreshToken": "string, required, not blank"
}Success response (200):
{
"token": "new-jwt-access-token",
"username": "alice",
"refreshToken": "new-uuid-refresh-token"
}Error responses:
- 400 validation failure
- 401 refresh token missing, revoked, expired, or unknown
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated (EMPLOYEE, ANALYST, ADMIN) |
Request body: none.
Success response (200): empty body.
Error responses:
- 401 missing or invalid authentication
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | No |
| Required role | Public |
Request body:
{
"email": "string, required, valid email"
}Success response (200): empty body.
Error responses:
- 400 validation failure
- 500 unexpected server error
Notes: unknown email is handled silently and still returns 200.
| Property | Value |
|---|---|
| Auth required | No |
| Required role | Public |
Request body:
{
"token": "string, required, not blank",
"newPassword": "string, required, min length 8"
}Success response (200): empty body.
Error responses:
- 400 validation failure
- 401 invalid, expired, or already used reset token
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | No |
| Required role | Public |
Query params:
- token: required
Success response (200): empty body.
Error responses:
- 401 invalid, expired, or already used verification token
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN |
Request body: none.
Success response (200): paginated UserResponse.
{
"content": [
{
"id": 1,
"username": "alice",
"email": "alice@example.com",
"emailVerified": false,
"status": "ACTIVE",
"roles": ["ADMIN"],
"createdAt": "2026-04-05T12:00:00"
}
],
"pageable": {},
"last": true,
"totalPages": 1,
"totalElements": 1,
"size": 20,
"number": 0,
"sort": {},
"first": true,
"numberOfElements": 1,
"empty": false
}Error responses:
- 401 unauthenticated
- 403 insufficient role
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN or own profile |
Request body: none.
Success response (200): UserResponse.
Error responses:
- 401 unauthenticated
- 403 not own profile and not admin
- 404 user not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN |
Request body:
{
"username": "string, required, not blank",
"email": "string, required, valid email",
"password": "string, required, min length 8",
"role": "string, required, one of ADMIN ANALYST EMPLOYEE"
}Success response (201): UserResponse.
Error responses:
- 400 validation failure
- 403 insufficient role
- 404 role not found
- 409 email already registered
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN or own profile |
Request body:
{
"username": "string, optional",
"email": "string, optional, valid email when present"
}Success response (200): UserResponse.
Error responses:
- 400 validation failure
- 403 not own profile and not admin
- 404 user not found
- 409 email already registered
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN |
Request body: none.
Success response (204): empty body.
Error responses:
- 403 insufficient role
- 404 user not found
- 409 deleting admin user is blocked
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN |
Request body:
{
"status": "ACTIVE | INACTIVE | SUSPENDED"
}Success response (200): UserResponse.
Error responses:
- 400 validation failure
- 403 insufficient role
- 404 user not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated |
Query params:
- page (optional, default 0)
- size (optional, default Spring pageable)
- sort (optional)
Success response (200): paginated ActivityResponse.
{
"content": [
{
"id": 10,
"userId": 1,
"action": "LOGIN_SUCCESS",
"entityType": "AUTH",
"entityId": "1",
"metadata": "{\"ip\":\"127.0.0.1\"}",
"createdAt": "2026-04-05T12:00:00"
}
],
"pageable": {},
"last": true,
"totalPages": 1,
"totalElements": 1,
"size": 20,
"number": 0,
"sort": {},
"first": true,
"numberOfElements": 1,
"empty": false
}Error responses:
- 401 unauthenticated
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN |
Query params:
- userId (required)
- page (optional)
- size (optional)
Success response (200): paginated ActivityResponse.
Error responses:
- 400 missing userId or type mismatch
- 401 unauthenticated
- 403 insufficient role
- 404 user not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated (employees limited to own activity) |
Request body: none.
Success response (200): ActivityResponse.
Error responses:
- 401 unauthenticated
- 403 employee accessing another user activity
- 404 activity not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN |
Query params:
- page (optional)
- size (optional)
Success response (200): paginated ActivityResponse.
Error responses:
- 401 unauthenticated
- 403 insufficient role
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated |
Success response (200): RiskScoreResponse.
{
"id": 77,
"userId": 1,
"score": 64,
"reason": "Elevated risk due to frequent and off-hours activity.",
"calculatedAt": "2026-04-05T12:00:00"
}Error responses:
- 401 unauthenticated
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN, or EMPLOYEE when userId equals own id |
Success response (200): RiskScoreResponse.
Error responses:
- 400 type mismatch on userId
- 401 unauthenticated
- 403 employee accessing another user
- 404 user not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN, or EMPLOYEE when userId equals own id |
Success response (200): paginated RiskScoreResponse.
Error responses:
- 400 type mismatch on userId
- 401 unauthenticated
- 403 employee accessing another user
- 404 user not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated |
Success response (200): paginated RiskScoreResponse.
Error responses:
- 401 unauthenticated
- 500 unexpected server error
Compatibility endpoint that also exists:
- GET /api/risk/user/{userId} (ANALYST or ADMIN only)
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated |
Query params:
- status (optional: OPEN, UNDER_INVESTIGATION, ACKNOWLEDGED, RESOLVED)
- page, size, sort (optional)
Success response (200): paginated AlertResponse.
{
"content": [
{
"id": 15,
"userId": 1,
"riskScoreId": 77,
"severity": "HIGH",
"message": "Risk score 64 detected for user alice.",
"status": "OPEN",
"createdAt": "2026-04-05T12:00:00",
"updatedAt": "2026-04-05T12:00:00"
}
],
"pageable": {},
"last": true,
"totalPages": 1,
"totalElements": 1,
"size": 20,
"number": 0,
"sort": {},
"first": true,
"numberOfElements": 1,
"empty": false
}Error responses:
- 400 invalid status enum value
- 401 unauthenticated
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN |
Query params:
- status (optional)
- page, size, sort (optional)
Success response (200): paginated AlertResponse.
Error responses:
- 400 invalid status enum value
- 401 unauthenticated
- 403 insufficient role
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated (employees limited to own alert) |
Success response (200): AlertResponse.
Error responses:
- 401 unauthenticated
- 403 employee accessing another user alert
- 404 alert not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated (employees limited to own alert) |
Request body: none.
Success response (200): AlertResponse with status ACKNOWLEDGED.
Error responses:
- 401 unauthenticated
- 403 employee modifying another user alert
- 404 alert not found
- 409 illegal status transition
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated (employees limited to own alert) |
Request body: none.
Success response (200): AlertResponse with status RESOLVED.
Error responses:
- 401 unauthenticated
- 403 employee modifying another user alert
- 404 alert not found
- 409 illegal status transition
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated (employees limited to own alert) |
Request body:
{
"status": "OPEN | UNDER_INVESTIGATION | ACKNOWLEDGED | RESOLVED"
}Success response (200): AlertResponse.
Error responses:
- 400 validation failure or enum type mismatch
- 401 unauthenticated
- 403 employee modifying another user alert
- 404 alert not found
- 409 illegal status transition
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN |
Request body:
{
"assigneeUserId": "number, required"
}Success response (200): AlertResponse.
Error responses:
- 400 validation failure
- 401 unauthenticated
- 403 insufficient role
- 404 alert or assignee user not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN |
Request body: none.
Success response (204): empty body.
Error responses:
- 401 unauthenticated
- 403 insufficient role
- 404 alert not found
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated |
Success response (200): DashboardSummaryResponse.
{
"totalActivities": 120,
"latestRiskScore": 64,
"openAlertsCount": 2,
"criticalAlertsCount": 1,
"recentActivities": [
{
"id": 10,
"userId": 1,
"action": "LOGIN_SUCCESS",
"entityType": "AUTH",
"entityId": "1",
"metadata": "{\"ip\":\"127.0.0.1\"}",
"createdAt": "2026-04-05T12:00:00"
}
]
}Error responses:
- 401 unauthenticated
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ADMIN |
Success response (200): AdminDashboardResponse.
{
"totalUsers": 25,
"totalOpenAlerts": 6,
"averageRiskScore": 31.5,
"highRiskUserCount": 4
}Error responses:
- 401 unauthenticated
- 403 insufficient role
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | Any authenticated |
Success response (200):
- EMPLOYEE receives DashboardSummaryResponse
- ANALYST and ADMIN receive AdminDashboardResponse
Error responses:
- 401 unauthenticated
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN |
Success response (200): array of RiskTrendResponse.
[
{
"period": "2026-W14",
"averageScore": 42.3,
"highRiskCount": 5
}
]Error responses:
- 401 unauthenticated
- 403 insufficient role
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | Yes |
| Required role | ANALYST or ADMIN |
Success response (200): AlertStatsResponse.
{
"totalOpen": 6,
"totalUnderInvestigation": 3,
"totalResolved": 9,
"bySeverity": {
"LOW": 4,
"MEDIUM": 6,
"HIGH": 5,
"CRITICAL": 3
}
}Error responses:
- 401 unauthenticated
- 403 insufficient role
- 500 unexpected server error
| Property | Value |
|---|---|
| Auth required | No |
| Required role | Public |
Success response:
- 200 when DB connected
- 503 when DB disconnected
Body fields: status, application, database, timestamp.
Access tokens are issued at registration and login as field token in AuthResponse.
Claims include subject (username) and roles.
Lifetime is controlled by JWT_EXPIRATION_MS.
Refresh tokens are stored in refresh_tokens table with expiry_date and revoked flag.
Rotation flow:
- Client submits refresh token to POST /api/auth/refresh.
- Existing token is validated.
- Existing token is revoked.
- New refresh token is created and returned with a new access token.
Revocation flow:
- POST /api/auth/logout revokes all refresh tokens for the current user.
- EMPLOYEE: own profile, own activity, own risk data, own alerts, personal dashboard.
- ANALYST: cross-user read access for activity, risk, alerts, and analytics endpoints; can assign alerts.
- ADMIN: full access including user CRUD and admin dashboard.
URL-level rules are defined in SecurityConfig and method-level rules are enforced with PreAuthorize on controller methods.
Passwords are stored as BCrypt hashes through PasswordEncoder. Plain-text password persistence is never used.
Registration and admin user creation trigger verification token generation and dev email dispatch logging.
Token verification endpoint marks email_verified=true when token is valid.
Current behavior: login does not block unverified accounts yet; verification state is stored and available in user response payload.
Forgot-password creates a one-time token (30 minute TTL) if email exists and returns 200 regardless.
Reset-password validates token existence, expiry, and used flag; then updates password hash and marks token as used.
Silent failure behavior for unknown email is implemented to prevent account enumeration.
Current backend suite status is 159 tests with 0 failures.
Test types in the project include:
- Unit tests
- Repository slice tests with DataJpaTest
- Integration tests with SpringBootTest and MockMvc
Run all tests:
./mvnw.cmd testRun a single test class:
./mvnw.cmd -Dtest=AuthControllerTest testTest profile behavior:
- Isolated datasource is used for tests.
- No real email provider is used.
- Dev email service writes reset and verification links to logs.
Coverage includes auth flow, token lifecycle, user management, activity endpoints, risk scoring, alert lifecycle, dashboard aggregation, and authorization rules by role.
- Base URL for development: http://localhost:8081
- Protected route header: Authorization: Bearer access_token
- Content-Type: application/json
- Call POST /api/auth/register or POST /api/auth/login.
- Store token and refreshToken from response. Preferred storage is in-memory state or secure HttpOnly cookies; do not store tokens in localStorage.
- Attach Authorization bearer token to every protected API call.
- On any 401 from protected API, call POST /api/auth/refresh with refreshToken.
- If refresh also returns 401, treat session as expired and redirect to login.
- On logout, call POST /api/auth/logout, then clear client-side auth state.
- EMPLOYEE: show personal dashboard, own activity timeline, own alerts.
- ANALYST: show global activity, global risk views, global alert management, hide admin-only controls.
- ADMIN: show all analyst views plus user management and admin dashboard widgets.
Login success response:
{
"token": "jwt-access-token",
"username": "alice",
"refreshToken": "uuid-refresh-token"
}Register success response:
{
"token": "jwt-access-token",
"username": "alice",
"refreshToken": null
}Activity list response (paginated):
{
"content": [
{
"id": 1,
"userId": 1,
"action": "LOGIN_SUCCESS",
"entityType": "AUTH",
"entityId": "1",
"metadata": "{\"ip\":\"127.0.0.1\"}",
"createdAt": "2026-04-05T12:00:00"
}
],
"pageable": {},
"last": true,
"totalPages": 1,
"totalElements": 1,
"size": 20,
"number": 0,
"sort": {},
"first": true,
"numberOfElements": 1,
"empty": false
}Risk score response:
{
"id": 77,
"userId": 1,
"score": 64,
"reason": "Elevated risk due to frequent and off-hours activity.",
"calculatedAt": "2026-04-05T12:00:00"
}Alert response:
{
"id": 15,
"userId": 1,
"riskScoreId": 77,
"severity": "HIGH",
"message": "Risk score 64 detected for user alice.",
"status": "OPEN",
"createdAt": "2026-04-05T12:00:00",
"updatedAt": "2026-04-05T12:00:00"
}Dashboard summary response:
{
"totalActivities": 120,
"latestRiskScore": 64,
"openAlertsCount": 2,
"criticalAlertsCount": 1,
"recentActivities": []
}400 indicates validation or parameter type errors. Response shape:
{
"timestamp": "2026-04-05T12:00:00Z",
"status": 400,
"error": "Validation failed"
}401 indicates unauthenticated requests or invalid token lifecycle state. Refresh token or redirect to login.
403 indicates authenticated but not authorized. Hide forbidden UI actions and show access denied state.
404 indicates resource not found. Show empty or not found view.
500 indicates server-side failure. Show generic error UI and log diagnostic context.
- Real email sending provider in non-dev environments
- Rate limiting
- Redis-backed token blacklist/revocation store
- Dedicated audit logging subsystem
- Frontend UI implementation itself
- main: stable, production-ready history only
- develop: integration branch for completed work
- feature/*: one branch per feature, branched from develop, merged back using no-ff
- fix/*: one branch per bug fix, branched from develop, merged back using no-ff
- Never commit directly to main or develop.
- Always branch from develop.
- Always merge using --no-ff to preserve integration history.
- Never force push to main.
- Run full test suite before merging into develop.