Problem and users
2.1 What students actually experience
A student with an idea faces four questions in sequence:
- Who is available? There is no reliable inventory of students who want to build something this semester.
- Who is capable? Self-reported skills are noisy. A GitHub profile shows code but not intent, availability, or role fit.
- Who is compatible? Skill overlap is necessary but not sufficient. Communication style, ambition, and prior delivery matter.
- What happens after the match? Most tools stop at introduction. The team still needs a workspace, a plan, and a way to close its own skill gaps.
The existing tools each solve one slice:
| Tool | Strength | Why it does not solve campus team formation |
|---|---|---|
| WhatsApp / Discord | Reach and immediacy | No structured skills, roles, capacity, or search; history is unsearchable |
| Professional identity | Optimized for permanent jobs, not transient campus projects; heavy cold-outreach friction | |
| GitHub | Evidence of code | Does not express availability, role preference, or team intent |
| University portals | Administrative data | Built for records, not collaboration |
| Generic AI chat | Ideation | Can suggest a team, but cannot execute the workflow or persist state |
2.2 Design requirements
The product requirements follow directly from those gaps:
- A trust boundary. Matching should happen inside a college community, not across the entire internet. This improves relevance and reduces cold-start risk.
- Structured demand. A project must express roles, headcount, and required skills — not just a paragraph of text.
- Evidence-based supply. Profiles should combine self-reported skills, resume-extracted skills, achievements, and prior project history.
- Two-sided discovery. Owners need candidate recommendations; students need project recommendations. Both directions must exist.
- Explainable ranking. A score alone is not enough. The user needs matched skills, a confidence level, and a rationale.
- A post-match workspace. Chat, notes, notifications, and task tracking keep the team operating after the introduction.
- A growth loop. The project's missing skills should become a learning plan, not a dead end.
SkillSync is designed around those seven requirements. The rest of this document explains how.
| Persona | Job to be done | What SkillSync gives them |
|---|---|---|
| The student builder | "I want to join a project that uses my skills and stretches me." | A profile, AI-ranked project recommendations, applications, invitations, and a roadmap for gaps |
| The project owner | "I have an idea and need specific roles filled." | Structured project creation, role/skill modeling, ranked candidates, invitations, and team management |
| The team member | "I want to collaborate without losing context." | Project chat, direct messages, notes, notifications, and shared task visibility |
| The career-focused learner | "I want to know what to learn next." | Resume-derived skill evidence, AI insights, a curriculum, and checkpoints converted into todos |
| The college community | "I want stronger collaboration without administration overhead." | College-scoped discovery and a shared skill graph (multi-college support is architecturally present but not yet product-complete — see §17) |
The primary user is the student builder. The primary buyer, in a campus context, would be the college or student organization. The product is deliberately not a job board: the unit of value is a project team, not a permanent placement.
What I built
Finding a teammate for a college project is a coordination problem disguised as a people problem. Students know the skills their team is missing, but they do not know who has those skills, whether those people are available, whether they have shipped anything before, or whether they will follow through. The search happens across WhatsApp groups, Discord servers, hallway conversations, and spreadsheets. The result is predictable: strong students cluster, weaker students get excluded, and projects stall before they start.
SkillSync treats the problem as an orchestration problem. It gives students a structured professional identity, gives project owners a structured model of demand (roles, headcount, required skills), and puts a ranking system between the two. The ranking system is deliberately layered: deterministic filters first, an LLM second, deterministic business policy last. The team, once formed, does not disappear into a group chat — it gets project messaging, direct messages, notes, notifications, and a learning plan tied to the work.
The platform is built as two deployable applications:
SkillSyncWeb— a React 19 single-page application with feature-based modules, TanStack Query for server state, Axios interceptors for authentication, STOMP for chat, and SSE for live notifications.skillsync-ai— a Spring Boot 4 / Java 21 modular monolith organized into 13 domain modules, with PostgreSQL for persistence, Redis for OTP/rate-limit state, Kafka for durable AI work, Spring Modulith events for in-process domain reactions, and two AI providers with different roles.
The central engineering bet: matching quality is a data-modeling and ranking problem before it is a model problem. Sending every student to an LLM is expensive, slow, and unexplainable. Scoping the candidate pool to the same college, filtering by skill overlap, and sending only a bounded shortlist to a single batched model call produces a system that is cheaper, faster, and easier to defend. The model supplies semantic judgement; the application supplies eligibility, invariants, and policy.
The project is a working MVP, not a demo shell. Resume ingestion, matching, invitations, applications, team capacity, chat, notifications, roadmap generation, and task tracking are implemented end to end. It also has real rough edges — dormant subsystems, stubbed scoring inputs, and a few frontend links that drift from the route table. Those are documented rather than hidden, because knowing where a system is unfinished is part of engineering it.
This is the implemented flow, not an aspirational one. Technical notes are included because the product experience depends on them.
Step 1 — Create an account and verify identity
A student registers with email and password. The backend creates an inactive user, assigns the deployment's active college, and sends a six-digit OTP by email. The OTP is stored as a SHA-256 hash with a five-minute TTL and a 45-second resend cooldown. Verification activates the account. Name and profile details are captured later during onboarding. Alternatively, Google OAuth2 completes the same identity flow without a password.
Why it matters: the verified email is the first trust signal. The OTP design avoids storing plaintext codes and prevents resend abuse.
Step 2 — Build an evidence-bearing profile
Onboarding captures the full name, graduation year, and professional bio. The profile editor then adds headline, department, social links, avatar, skills with proficiency levels, achievements, and previous projects. A profile-completion tracker turns the empty state into a sequence of concrete actions.
Why it matters: the matching pipeline can only rank what the profile expresses. The completion tracker is a product-level fix for cold-start data quality.
Step 3 — Upload a resume and let the system extract skills
The browser requests backend-signed Cloudinary upload parameters, uploads the PDF directly to Cloudinary, then sends the resulting URL to the backend. The backend publishes a ResumeUploadedEvent to Kafka topic resume-uploaded-topic. The AI module downloads the PDF, extracts text with Apache PDFBox, sends it to Gemini, parses the structured JSON, persists an analysis result, and publishes ResumeAnalysisCompletedEvent to resume-analyzed-topic.
Two consumers react:
- The user-profile module upserts the extracted skills onto the student's profile and stores the AI-generated resume summary.
- The notification module persists a notification and pushes it over SSE, which the SPA renders as a live toast and a profile refresh.
Why it matters: the expensive, failure-prone work is asynchronous and durable. The user is not holding an HTTP connection open while a PDF is parsed and a model responds.
Step 4 — Understand strengths and gaps
The profile includes an AI insights tab. Gemini receives the profile context and returns a structured analysis of strengths, skill gaps, and suggested focus areas. The result is stored on the profile with an insightsOutdated flag so it can be refreshed when the profile changes.
Step 5 — Create a project with real structure
The project wizard creates a project in DRAFT status. The owner adds roles; each role has a title, headcount, and required skills. The backend enforces:
- the sum of role headcounts plus the owner cannot exceed
maxTeamSize; - a project must have at least one role before it can be published;
- publishing aggregates all role skills into the project's required-skill set and moves the project to
RECRUITING.
The status enum is explicit: DRAFT, RECRUITING, IN_PROGRESS, COMPLETED, ARCHIVED, CANCELLED. The transitions implemented in the current service layer are DRAFT → RECRUITING → IN_PROGRESS; the remaining states are defined in the model but are not yet reachable through the current endpoints.
Why it matters: a free-text project post cannot be matched reliably. Roles and headcount turn an idea into a queryable specification.
Step 6 — Discover matches in both directions
The owner opens the AI matchmaker and requests candidates. The student opens AI discovery and requests projects. Both requests enter the same three-stage funnel, described in §9. The result is a ranked list with a final score, semantic score, confidence, matched skills, and a human-readable rationale.
Generation is manual, not automatic on publish. The auto-matchmaking listener exists but is intentionally disabled in the current code, so AI spend happens only when a user asks for it.
Step 7 — Evaluate before committing
A recommendation is not a black box. The UI shows the score, the skills that matched, the confidence band, and the rationale. The owner can open the candidate's public profile to inspect skills, achievements, previous projects, and AI insights. The student can inspect the project's roles and owner.
Why it matters: trust in a ranking system comes from inspectability. A percentage without an explanation is worse than a shortlist with one.
Step 8 — Invite, apply, and form the team
Owners can invite a recommended student to a specific role. Students can apply to a project with a cover message. The backend prevents duplicate pending invitations and duplicate pending applications, and it prevents a student from applying to a project they already own or have been invited to.
When an application or invitation is accepted, a TeamMemberAcceptedEvent is published. The project module:
- rejects duplicate membership idempotently;
- verifies project capacity;
- verifies role capacity (
filledCount < headcount); - creates the active team member;
- increments the role's filled count;
- transitions the project to
IN_PROGRESSwhen the team reachesmaxTeamSize.
Step 9 — Collaborate in the workspace
The team gets project chat over STOMP/WebSocket, one-to-one direct messages, a notes dashboard with AI summarization, and persisted notifications. Project chat is broadcast to a project topic; direct messages are routed to a user queue. Read receipts are handled through REST.
Step 10 — Close the loop with a learning plan
The AI roadmap page lets a student describe a target role, current knowledge, and perceived gaps. The backend combines that with platform skills, project history, and the last 50 chat messages, asks Gemini for a curriculum, and generates tasks for the first topic. Each checkpoint becomes a todo. Completing a todo marks its checkpoint complete; when every checkpoint is complete, the roadmap records the current topic, selects the next topic, and generates the next set of tasks automatically.
This is the growth loop: project work reveals a gap → the gap becomes a roadmap → the roadmap becomes tasks → completed tasks become evidence for the next match.
| Capability | User-facing outcome | Implementation | State |
|---|---|---|---|
| Authentication | Register, verify email, log in, Google sign-in | JWT, OTP, OAuth2 exchange code, rate limiting | Verified |
| Profile and evidence | A professional student profile | Profile, skills, achievements, prior projects, social links, avatar | Verified |
| Resume intelligence | Skills appear automatically after upload | Cloudinary → Kafka → PDFBox → Gemini → Kafka → profile | Verified |
| AI profile insights | Strengths, gaps, and focus areas | Gemini profile analysis stored as JSON | Verified |
| Project modeling | Roles, headcount, required skills, lifecycle | Project, ProjectRole, TeamMember; capacity rules | Verified |
| Owner → candidate matching | Ranked candidates with explanations | College scope → overlap → Groq batch → policy | Verified, manual trigger |
| Student → project matching | Ranked projects with explanations | College scope → overlap → Groq batch → policy | Verified, manual trigger |
| Team formation | Invitations, applications, accepts, capacity | Application/Invitation workflows; idempotent membership | Verified |
| Project collaboration | Chat, DMs, read receipts | STOMP/WebSocket, REST history, user queues | Verified |
| Notifications | Live in-app updates | Persisted notifications + SSE | Verified |
| AI roadmap | Curriculum, checkpoints, mentor chat | Gemini curriculum + topic tasks + tool-calling chat | Verified (custom path) |
| Roadmap → tasks | Checkpoints become todos; completion advances the topic | Modulith events, todo dedupe, checkpoint cascade | Verified |
| Notes | Create, tag, pin, summarize | Notes CRUD + asynchronous Gemini summary | Verified |
| Analytics | Dashboard metrics | Aggregation DTO with cache annotation | Partial — endpoint exists, current dashboard does not call it |
| Public explore | Browse without an account | Static placeholder grid | Gap — authenticated /discover is the real experience |
| Auto-match on publish | Recommendations appear without a click | Listener intentionally disabled | Partial |
| Gap-based roadmap | Roadmap derived from a project skill gap | Code exists; producer is currently dormant | Partial |
The capability map is intentionally blunt. A portfolio case study that claims everything is finished is less credible than one that distinguishes "working" from "wired" from "planned."
Architecture
6.1 Four loops around one identity graph
SkillSync is easiest to understand as four loops that share a single identity and evidence graph.
flowchart TD
A["Profile + resume evidence"] --> B["Structured project + roles"]
B --> C["Deterministic shortlist"]
C --> D["LLM semantic ranking"]
D --> E["Team formation"]
E --> F["Collaboration + delivery"]
F --> G["Skill gap"]
G --> H["Roadmap + tasks"]
H --> A
- Identity loop: registration, verification, profile, resume extraction. The output is a machine-readable picture of what a student can do.
- Matching loop: structured demand meets structured supply, narrowed deterministically and ranked semantically. The output is a shortlist with explanations.
- Execution loop: team formation, chat, direct messages, notes, notifications, task status. The output is delivery and evidence.
- Growth loop: skill gap → curriculum → checkpoints → todos → completed work → stronger evidence. The output is a better profile and a better next match.
The loops are not marketing language; they map to actual subsystems and events. The growth loop, in particular, is implemented as an event chain rather than a static recommendation list.
6.2 Deterministic shell, probabilistic core
The most important architectural decision is the separation of deterministic and probabilistic responsibilities.
| Concern | Deterministic shell | Probabilistic core |
|---|---|---|
| Eligibility and trust | College scope, ownership checks, role eligibility, capacity | — |
| Ranking | Skill-overlap shortlist, candidate caps, policy adjustments, threshold | Semantic score, confidence, matched skills, rationale |
| State and invariants | Status machines, idempotency, expiry, membership rules | — |
| Content generation | — | Resume extraction, skill gap, curriculum, mentor chat, note summaries |
| Failure handling | Explicit FAILED states, retries, fallbacks | Timeouts, null responses, malformed JSON |
The rule is simple: the model never owns a business invariant. It can suggest that a candidate is a 78% fit. It cannot decide that a team has capacity, that an invitation is still valid, or that a project is published. Those decisions stay in application code, where they can be tested and reasoned about.
This is also why the matching pipeline is a funnel rather than a single prompt. The deterministic stages are not a preprocessing detail; they are the part of the system that makes the AI affordable and auditable.
6.3 Fast path, slow path, push path
| Path | Examples | Mechanism | User experience |
|---|---|---|---|
| Fast | Login, profile read, project feed, todo toggle, notes CRUD | Synchronous HTTP + PostgreSQL | Immediate response |
| Slow | Resume analysis, batch matching, roadmap generation, note summarization | Kafka or Modulith events, async executors, status records | Progress state, polling, or notification |
| Push | Chat messages, notifications | STOMP/WebSocket, SSE | Real-time update |
The slow path is where most of the engineering difficulty lives. The rest of this document focuses there.
6.4 Design principles that emerge from the code
- Narrow before you prompt. College scope and skill overlap reduce the model's input before a token is spent.
- Batch the expensive call. One Groq request scores a shortlist; it does not score the college one student at a time.
- Every async workflow has a status. Resume analysis, roadmaps, notes, and recommendations all have explicit states.
- Persist before push. Notifications and messages are written to the database before being broadcast, so a dropped connection does not lose data.
- Events carry IDs, not entity graphs. Cross-module events reference users, projects, roles, and checkpoints by ID, which keeps module boundaries intact.
- Boundaries need a test. Spring Modulith's
ApplicationModules.verify()runs in the test suite and fails the build if a module imports another module's internals.
7.1 Topology
flowchart LR
subgraph Browser["Browser"]
SPA["React 19 SPA<br/>Vite · TypeScript · Tailwind v4"]
end
subgraph Edge["Hosting"]
Vercel["Vercel<br/>static SPA"]
Render["Render<br/>Docker · Spring Boot 4 · Java 21"]
end
subgraph App["Spring Boot modular monolith"]
SEC["Security chain<br/>rate limit · JWT · OAuth2"]
REST["20 controllers<br/>95 REST route mappings"]
STOMP["STOMP / WebSocket"]
SSE["SSE emitter registry"]
MOD["13 domain modules"]
EVT["Spring Modulith<br/>domain events"]
end
subgraph Infra["Managed infrastructure"]
PG[("PostgreSQL")]
REDIS[("Redis / Cacheify")]
KAFKA[("Kafka")]
CLOUD["Cloudinary"]
end
subgraph AI["AI providers"]
GEM["Google Gemini"]
GROQ["Groq<br/>Llama 3.1 70B"]
end
SPA --> Vercel
SPA -->|"HTTPS / REST"| Render
SPA <-->|"WSS / STOMP"| Render
SPA <-->|"SSE"| Render
Render --> SEC --> REST
Render --> STOMP
Render --> SSE
REST --> MOD
STOMP --> MOD
MOD --> PG
MOD --> REDIS
MOD --> EVT
EVT --> KAFKA
MOD --> CLOUD
MOD --> GEM
MOD --> GROQ
7.2 Backend modules
The backend is a modular monolith with 13 packages under com.skillsync.skillsync_ai. Each package is a Spring Modulith module with its own controllers, services, entities, repositories, and DTOs.
| Module | Responsibility | Representative classes |
|---|---|---|
authentication | Users, colleges, JWT, OTP, OAuth2, rate limiting, pluggable stores | AuthService, JwtService, OtpService, RateLimitFilter, OAuth2AuthenticationSuccessHandler |
userprofile | Profiles, skills, achievements, prior projects, resume URL, Cloudinary signatures, AI insights | UserProfileService, SkillManagementService, AchievementService, ResumeAnalysisListener |
project | Projects, roles, members, lifecycle, ownership, capacity | ProjectManagementService, Project, ProjectRole, TeamMember |
matching | Recommendations, applications, invitations, scoring, dashboards | RecommendationOrchestrator, RecommendationScoringService, TeamMatchingService |
ai | Gemini/Groq gateways, resume analysis, skill gap, roadmaps, checkpoints, agent chat, note summaries | GeminiApiService, GroqApiService, BatchSkillGapAnalyzer, RoadmapService, AiChatOrchestrator |
todo | Todos, subtasks, status, roadmap event consumption | TodoService, TodoController |
notes | Notes CRUD, tags, pinning, summary state machine | NoteService, NoteSummarizationListener |
messaging | Project chat, direct messages, STOMP configuration, inbox, read receipts | WebSocketConfig, AuthChannelInterceptor, ProjectChatService, DirectMessageService |
notification | Persisted notifications, SSE registry, event listeners | NotificationEventListener, SseConnectionService |
chat | AI conversation threads and message history | ChatService, Conversation, ChatMessage |
analytics | Dashboard aggregation DTO | AnalyticsService, AnalyticsController |
common | Shared events, API response envelope, exceptions, enums, Kafka config | ApiResponse, GlobalExceptionHandler, KafkaConfig, event records |
config | Async executor and cross-cutting configuration | AsyncConfig |
Why a modular monolith? The domain has strong transactional invariants: a team cannot exceed capacity, a role cannot be overfilled, an invitation cannot be accepted twice. A monolith keeps those invariants inside one database transaction and one deployable. At the same time, the module boundaries, gateway interfaces, and event bus make future extraction possible. The architecture is a bet that clear boundaries matter more than process isolation at this stage.
7.3 Boundaries in practice
The code uses three boundary mechanisms:
- Gateway interfaces for cross-module reads. For example,
UserProfileGateway,ProjectGateway,TodoGateway,ChatGateway,AiGateway, andAiMatchingGatewayexpose narrow, intention-revealing methods instead of leaking repositories across modules. - Domain events for cross-module writes. A module publishes an event; interested modules react. The publisher does not know who listens.
- A Modulith verification test.
ModulithVerificationTestcallsApplicationModules.of(...).verify(), which fails if a module imports another module's internal classes. This turns architecture diagrams into an enforceable build rule.
The result is not microservices purity. It is a pragmatic monolith with seams that are visible, testable, and cheap to change.
7.4 API surface
All routes except /api/auth/** and the WebSocket handshake require a valid JWT. The API is stateless.
| Group | Base path | Representative operations |
|---|---|---|
| Auth | /api/auth | Register, resend OTP, verify, login, OAuth2 exchange, ping |
| Profile | /api/v1/profile/me | Get profile, update basic info, social links, resume, avatar, search, public profile |
| Skills | /api/v1/skills | Search skills, list mine, add, remove |
| Achievements | /api/v1/userprofile/achievements | Create, update, delete |
| Prior projects | /api/v1/profile/me/projects | List, create, update, delete |
| Uploads | /api/uploads | Cloudinary upload signature |
| Projects | /api/v1/projects | Create, update, delete, publish, close, feed, roles, members, leave, transfer ownership |
| Matching | /api/v1/matching/dashboard | My applications, invitations, recommendations; generate; project-side views |
| Applications | /api/v1/applications | Apply, decide, withdraw, list by project, list mine |
| Invitations | /api/v1/invitations | Invite, respond, revoke, list by project, list mine |
| AI | /api/v1/ai | Resume analysis status, skill gap for project |
| Roadmaps | /api/v1/roadmaps | Generate from gap, generate custom, active roadmap, chat, checkpoint status |
| Todos | /api/v1/todos | List, create, update, status, subtask toggle, delete |
| Notes | /api/v1/notes | List, get, create, update, delete, summarize |
| Messaging | /api/v1/messages, /api/v1/projects/{id}/messages | Inbox, direct history, mark read, project history |
| Notifications | /api/v1/notifications | Stream (SSE), list, unread count, mark read, mark all read |
| Analytics | /api/v1/analytics | Dashboard metrics |
| WebSocket | /ws | STOMP handshake, project topics, user queues |
7.5 Data model highlights
The schema is split into domain schemas (auth, profile, project, matching, ai, todo, notes, messaging, notification, chat). Cross-module references are IDs, not JPA relationships, which keeps module ownership clear.
| Aggregate | Key fields | Relationships |
|---|---|---|
User / College | Email, password hash, role, active flag, college | A user belongs to a college |
StudentProfile | Name, headline, bio, department, graduation year, resume URL, summary, AI insights, visibility | One per user; owns skills and achievements |
Skill / StudentSkill | Canonical skill name, category; proficiency level, source | StudentSkill joins profile to skill |
Project / ProjectRole | Title, description, status, max team size, required skills; role title, headcount, filled count | A project has roles and members |
TeamMember | User ID, role, status, joined/left timestamps | Active membership is unique by business rule |
Application / Invitation | Project, user, role, status, message, expiry | Pending duplicates are rejected in service logic |
ProjectRecommendation | Project, user, semantic score, final score, confidence, matched skills, rationale, prompt version, expiry | Unique on (project_id, user_id); optimistic @Version |
SkillAnalysisResult | Resume URL, extracted skills, summary, status, error | One analysis record per upload |
SkillGapAnalysis / SkillGapItem | Target role, matched skills, missing skills, match percentage | Feeds the gap-based roadmap path |
Roadmap / Checkpoint | Title, curriculum, current topic, completed topics, status; title, description, order, subtasks, status | A roadmap has ordered checkpoints |
Todo | Title, status, priority, due date, source title, related entity ID, subtasks | Roadmap checkpoints map to todos via relatedEntityId |
Note | Title, content, tags, pinned, AI summary, summary status | Owned by a user; optional project link |
ProjectMessage / DirectMessage | Sender, content, timestamp, read state | Project topic or direct user queue |
Notification | Type, title, message, read flag, related entity ID | Persisted before SSE push |
11.1 Two event buses, two jobs
| Bus | Scope | Durability | Used for |
|---|---|---|---|
| Spring Modulith events | In-process, same application | Transactional outbox via JPA | Domain reactions that should be consistent with the local transaction |
| Kafka | External broker | Durable, replayable | Expensive AI work that should survive restarts and be decoupled from the request |
Using both is a deliberate split. Modulith events keep the monolith cohesive; Kafka keeps the slow, failure-prone AI pipeline from blocking the user-facing transaction and provides a path to extract the AI worker later.
11.2 Resume ingestion pipeline
Browser
→ GET /api/uploads/signature
→ direct upload to Cloudinary
→ PUT /api/v1/profile/me/resume (URL)
→ Kafka: resume-uploaded-topic
→ AI module:
download PDF
PDFBox text extraction
Gemini structured analysis
persist SkillAnalysisResult (COMPLETED / FAILED)
Kafka: resume-analyzed-topic
→ userprofile module: upsert skills + summary
→ notification module: persist notification + SSE push
Why this shape:
- The upload is direct-to-Cloudinary, so the backend never proxies large files.
- PDF extraction and model inference are off the request thread.
- The analyzed event has two independent consumers, so profile updates and notifications do not block each other.
- Failures are persisted on the analysis record.
11.3 Roadmap → todo → checkpoint loop
This is the most interesting event chain in the product because it creates a self-continuing workflow.
POST /api/v1/roadmaps/generate-custom
→ deactivate existing active roadmaps (and their todos)
→ gather profile, skills, projects, last 50 chat messages
→ Gemini: curriculum for target role
→ persist Roadmap (ACTIVE, curriculum, currentTopic)
→ Gemini: tasks for first topic
→ persist Checkpoints (PENDING, ordered, with subtasks)
→ publish RoadmapGeneratedEvent
→ Todo module creates Todos with relatedEntityId = checkpointId
(deduplicated against existing related IDs)
User completes a Todo
→ TodoCompletedEvent
→ AI module marks the Checkpoint COMPLETED
→ if all checkpoints are complete:
mark current topic completed
select next topic from curriculum
Gemini: tasks for next topic
persist new Checkpoint
publish RoadmapGeneratedEvent
→ Todo module creates the next tasks
The key design choices:
- The todo is the user's unit of work; the checkpoint is the roadmap's unit of progress.
relatedEntityIdlinks them without a JPA relationship across modules. - Idempotency is explicit.
findExistingRelatedEntityIdsprevents duplicate todos if an event is replayed. - The roadmap is a state machine, not a static document.
currentTopic,completedTopics, and checkpoint status drive the next generation. - Deactivation is part of the flow. Creating a new roadmap retires the old one and its outstanding todos, so the user is not pursuing two conflicting plans.
11.4 Team formation event chain
Owner accepts application / Student accepts invitation
→ TeamMemberAcceptedEvent
→ Project module:
duplicate membership check
project capacity check
role capacity check
create TeamMember (ACTIVE)
increment role.filledCount
if team full and RECRUITING → IN_PROGRESS
→ Notification module:
persist welcome notification
SSE push
The event carries projectId, projectRoleId, and userId — only IDs. The project module re-loads the aggregates it needs and re-validates capacity at the moment of acceptance. That is important: a recommendation is a suggestion, not a reservation.
11.5 Notes summarization state machine
User clicks Summarize
→ status = PROCESSING (locks duplicate requests)
→ NoteSummarizationRequestedEvent
→ AI module: Gemini summary
→ NoteSummarizationCompletedEvent / FailedEvent
→ Notes module: set summary, status, timestamp
Editing a note clears the AI summary and resets the status to IDLE. That prevents a stale summary from being displayed against changed content — a small detail that matters for trust.
11.6 Idempotency and duplicate protection
| Workflow | Duplicate risk | Protection |
|---|---|---|
| Roadmap → todos | Kafka/event replay creates duplicate tasks | findExistingRelatedEntityIds by relatedEntityId |
| Team membership | Double accept or duplicate event | Active-membership check before insert |
| Role capacity | Two accepts race for the last slot | filledCount >= headcount check at acceptance |
| Applications | Student applies twice | Pending-application existence check |
| Invitations | Owner invites twice | Pending-invitation existence check |
| Recommendations | Repeated generation creates duplicates | Unique (project_id, user_id) + upsert + optimistic @Version |
| Note summary | Double-click summarize | PROCESSING state lock |
Idempotency is not theoretical here. Kafka and Modulith listeners can both redeliver, and users double-click. The code handles the common cases explicitly.
12.1 Stateless JWT
- The API is stateless; no HTTP session is created.
JwtAuthenticationFilterextracts the bearer token, validates it, and populates the security context.- Tokens carry the user ID as a custom claim and the email as the subject.
- Default expiry is 24 hours, configurable through
app.security.jwt.expiration. SecurityConfigpermits only/api/auth/**,/api/v1/dev/**,/ws/**, and CORS preflight; everything else requires authentication.- Unauthenticated requests receive a
401from a dedicated entry point.
12.2 OTP verification
| Control | Implementation |
|---|---|
| Storage | SHA-256 hash, never the plaintext code |
| TTL | 5 minutes, enforced by the store |
| Resend cooldown | 45 seconds, enforced by the store |
| Comparison | MessageDigest.isEqual for constant-time comparison |
| Normalization | Email lower-cased/trimmed before hashing and lookup |
| Single use | Hash deleted on successful verification |
| Abuse control | Auth endpoints rate-limited per IP |
The OtpServiceTest verifies the hash is 64 hex characters, is not the plaintext code, accepts case-insensitive email, rejects wrong codes, consumes the key, and delegates cooldown correctly.
12.3 OAuth2 one-time exchange code
Putting a JWT directly in an OAuth2 redirect URL leaks it into browser history, referrer headers, and server logs. SkillSync avoids that:
- The OAuth2 success handler mints the JWT server-side.
- It generates a random UUID exchange code.
- It stores the token under that code for 30 seconds.
- It redirects the browser with only
?code=<uuid>. - The SPA calls
/api/auth/oauth2/exchange?code=...to exchange the code for the token. - The code is single-use and expires quickly.
This is a small piece of design with a large security payoff. The token never appears in the URL.
12.4 Rate limiting
- Applies to
/api/auth/**. - Limit: 5 requests per minute per IP.
- Store is pluggable:
- Redis + Bucket4j — distributed token bucket; default.
- Cacheify — atomic
INCR+PEXPIREfixed-window counter.
- Rejection returns
429withapplication/problem+jsonand aRetry-Afterheader. - Client IP is taken from
X-Forwarded-Forwhen present, otherwise the remote address.
The pluggable store is a real deployment consideration: Redis is not always available, and the abstraction lets the same security behavior run over an HTTP key-value service.
12.5 WebSocket authentication
The STOMP handshake at /ws is permitted through the HTTP security chain because the authentication happens at the messaging layer. AuthChannelInterceptor validates the JWT on the STOMP CONNECT frame and binds the principal. Project chat broadcasts to /topic/projects/{id}; direct messages route to /user/queue/messages. The principal is the user's identity, so user destinations are addressed by the authenticated principal rather than a client-supplied ID.
12.6 SSE authentication trade-off
The browser's native EventSource cannot set an Authorization header. The current frontend therefore appends the JWT as a query parameter:
/api/v1/notifications/stream?token=<jwt>
The backend's JWT filter accepts a bearer token from either the Authorization header or a token query parameter. The SPA uses the query form only for SSE, but the filter's acceptance is global, so any endpoint can be authenticated that way. This works, but query-string tokens can be captured by proxies, logs, and analytics. The honest assessment is that this is a known compromise, not a best practice. The next step is a short-lived, single-use stream ticket (or a same-site cookie session for the stream endpoint) so the long-lived JWT never appears in a URL.
12.7 Authorization and ownership checks
Authentication answers "who are you"; authorization answers "can you do this." The code checks ownership and role at the service layer:
- Only a project owner can generate recommendations, publish, close, add roles, remove members, or transfer ownership.
- Only the invited student can respond to an invitation.
- Only the applicant can withdraw an application.
- Only the roadmap owner can update a checkpoint or chat with the roadmap.
- Only the note owner can read, update, delete, or summarize a note.
- Profile visibility controls public search and public profile access.
These checks are not left to the frontend. The frontend hides controls for UX, but the backend enforces the rule.
12.8 Security posture summary
Strong points:
- Hashed OTPs with constant-time comparison and TTL.
- One-time OAuth2 exchange code instead of a token in the URL.
- Stateless JWT with a filter and explicit 401 handling.
- Per-IP rate limiting on the auth surface.
- Ownership checks on every mutating domain operation.
- College-scoped queries for matching.
- CORS restricted to local, Vercel, and Render origins.
Hardening backlog:
- No refresh-token rotation; a 24-hour access token is the only credential.
- Access token stored in
localStorage, which is readable by any script on the origin. - SSE token in the query string (see above).
- A development fallback signing key exists for local runs; production must override it.
- Rate limiting is scoped to auth endpoints; other endpoints rely on authentication and business limits.
These are appropriate for an MVP but should be addressed before handling sensitive data at scale.
13.1 Chat over STOMP
The messaging module uses Spring's STOMP support:
- Endpoint:
/ws. - Simple broker:
/topicand/queue. - Application prefix:
/app. - User destination prefix:
/user.
Project chat is broadcast to a project topic. Direct messages are sent to a user queue. The STOMP interceptor authenticates the CONNECT frame and binds the principal, so the server knows who is sending and who should receive.
Messages are persisted through the service layer before or alongside the push, so a disconnected client can load history through REST:
GET /api/v1/projects/{projectId}/messagesGET /api/v1/messages/inboxGET /api/v1/messages/direct/{otherUserId}POST /api/v1/messages/direct/{otherUserId}/read
13.2 Notifications over SSE
Notifications are a different problem from chat: they are server-initiated, one-way, and need to reach a user who may not have an open WebSocket. SSE is a good fit.
The flow:
- A domain event occurs (resume analyzed, application submitted, invitation sent, team member accepted, certificate uploaded).
NotificationEventListenerpersists aNotificationrow.SseConnectionService.sendEventToUserlooks up the user's emitter and sends a namednotificationevent.- The SPA updates the React Query cache, increments the unread count, and shows a toast.
- If the notification is
RESUME_ANALYZED, the SPA invalidates the profile query so the new skills appear.
The emitter registry is a ConcurrentHashMap<UUID, SseEmitter> with a one-hour timeout and cleanup callbacks for completion, timeout, and error. A connected event is sent immediately to confirm the stream.
Scale caveat: one emitter per user means one active stream per user. Multiple tabs or devices compete for the same entry. A multi-instance deployment would need Redis pub/sub or a message broker to fan out events across nodes. This is documented in §17.
13.3 Cold-start-aware connection status
The backend is deployed on Render's free tier, which sleeps after inactivity. During wake-up, the platform returns 502 or 503, which looks like an outage to a user.
ConnectionStatus handles this with a health probe:
- It calls
/api/auth/pingwith a cache-busting timestamp. 502/503→ "Waking Server..." (amber, spinner).- Any other HTTP response → "Connected" (green).
- Network error → "Server Offline" (red).
- Polling interval: 5 seconds while connecting/disconnected, 30 seconds once connected.
This is a small UX component with a real operational purpose: it converts an ambiguous failure into an understandable wait.
14.1 Feature-based organization
The frontend is organized by feature rather than by technical type:
| Feature | Responsibility |
|---|---|
auth | Login, register, OTP verification, OAuth callback, auth context, schemas |
userprofile | Profile view/edit, skills, achievements, prior projects, resume upload, AI insights |
projects | Project feed, creation wizard, details, applications, recommendations, discovery |
matching | (Consumed through project and dashboard APIs) |
messaging | Project chat, direct messages, inbox, STOMP hooks |
notifications | SSE connection, notification list, unread count, toasts |
notes | Notes dashboard, editor, AI summary |
roadmap | Roadmap API, generation, chat, checkpoint display |
todo | Todo list, subtask toggling, status updates |
This structure keeps a feature's API calls, components, hooks, and schemas close together. It also makes ownership obvious: a change to notifications does not require traversing a global components/ and services/ tree.
14.2 Server state vs client state
TanStack Query owns server state:
- Query keys are feature-scoped (
["profile"],["todos"],["notifications"],["projectRecommendations", projectId]). - Mutations invalidate the relevant queries.
- The notification provider writes directly into the query cache for instant UI updates.
- Polling is used where the backend is asynchronous (recommendation generation).
Local React state is reserved for transient UI concerns: modal visibility, form input, chat draft text, and generation spinners.
14.3 API layer and interceptors
src/lib/api/axios.ts creates a single Axios instance. attachInterceptors adds:
- Request: reads the token from
tokenStorageand setsAuthorization: Bearer <token>. - Response: on
401for non-auth endpoints, clears the token and invokes a registered unauthorized handler.
The unauthorized handler is registered by AuthProvider, which avoids importing React state into the HTTP layer. Auth endpoints are explicitly excluded from the global logout behavior so a failed login or wrong OTP can be shown inline instead of redirecting the user.
The base URL comes from VITE_API_BASE_URL, with a production fallback. The SSE context and some URLs use hardcoded production hosts, which is a portability gap documented in §17.
14.4 Routing
The route table is explicit in App.tsx:
- Public:
/,/explore,/about,/contact,/privacy-policy,/terms-of-service,/cookie-policy,/oauth2/callback. - Auth layout:
/login,/register,/verify. - Protected dashboard layout:
/dashboard,/profile,/profiles/:userId,/search,/onboarding,/roadmap,/projects,/projects/new,/projects/:projectId,/discover,/messages,/notes. - Fallback:
*→/dashboard.
The dashboard layout is the protected shell; the auth layout is the pre-login shell. The route structure is conventional and easy to reason about.
14.5 Design system and interaction
The UI is built on Tailwind CSS v4 with Radix primitives and shadcn-style local components. The visual language is deliberate:
- A warm off-white canvas with a strong primary/accent palette.
- Handwritten accent typography on landing and dashboard headings, which gives the product a human, campus-oriented feel.
- Framer Motion for reveal and page transitions.
- Lenis for smooth scrolling on the landing page.
- Three.js/OGL for hero visuals.
- Recharts for data visualization.
- Sonner for toasts.
- Responsive layouts across mobile, tablet, and desktop.
Radix primitives provide the accessibility baseline: focus management, keyboard navigation, and ARIA semantics for dialogs, menus, tabs, and popovers. That is preferable to hand-rolling interactive components and fixing accessibility later.
14.6 Forms and validation
react-hook-form handles form state and submission; zod defines the validation schemas. The auth forms, profile editor, project wizard, and onboarding flow use this pairing. Validation is enforced on the client for UX and on the server for correctness — the client is never the only line of defense.
14.7 UX details that matter
- Profile completion tracker: converts an empty profile into a checklist.
- Empty states: dashboard, notes, projects, and recommendations all have explicit empty-state copy rather than a blank screen.
- Loading states: skeletons and spinners are used during queries and AI generation.
- Optimistic feedback: notification read state and todo toggles update the cache immediately.
- Error toasts: mutations surface backend messages rather than failing silently.
- Cold-start pill: explains Render wake-up instead of showing a generic error.
- AI progress copy: "AI is analyzing...", "This usually takes 15–30 seconds", and a 180-second timeout set expectations.
14.8 Frontend gaps
The frontend is polished in the core flows but has some honest rough edges:
/exploreis a placeholder grid; the real discovery experience is the authenticated/discoverpage.ProjectRecommendationsModalopens/profile/${userId}, while the route table defines/profiles/:userId. That is a broken-link bug in the recommendations flow.- The dashboard contains some static widget copy (for example, "You have 3 active projects") rather than live counts.
- Some production URLs are hardcoded instead of derived from configuration.
- There is no frontend test suite.
These are listed in §17 with next steps.
15.1 Topology
- Frontend: Vercel static hosting.
vercel.jsonrewrites every path toindex.html, so client-side routes work on refresh. - Backend: Render Docker container. The image is built in two stages: Maven + JDK 21 for the build, JRE 21 Alpine for the runtime.
- Database: managed PostgreSQL.
- Cache/state: managed Redis (with the Cacheify HTTP alternative).
- Events: managed Kafka with SSL. The build copies
ca.peminto the runtime image and setsKAFKA_TRUST_STORE_LOCATION. - Media: Cloudinary. The backend signs uploads; the browser uploads directly.
- AI: Gemini and Groq over HTTPS.
15.2 Docker details
The Dockerfile reflects real deployment constraints:
- Multi-stage build keeps the runtime image small.
-Xmx300mcaps the JVM heap for a memory-constrained free instance.-Djava.net.preferIPv4Stack=trueavoids IPv6 resolution issues in the hosting environment.-Dio.netty.transport.noNative=trueavoids native transport problems on Alpine.- The Kafka CA certificate is copied into the runtime image.
- The container exposes port 8080.
These are not theoretical flags. They are the kind of adjustments that come from deploying a JVM service on a small cloud instance and debugging why it fails to start or connect.
15.3 Configuration and secrets
Configuration is environment-driven. The repository includes a Docker Compose file that references an external env file; the env file itself is ignored. The backend uses Spring Dotenv for local development. This document deliberately does not inspect or reproduce secret values.
15.4 Operational behavior
- Cold starts: Render free-tier instances sleep. The frontend's connection pill and retry behavior make this visible and tolerable.
- AI latency: 60-second provider timeouts and two retries; the UI polls for up to 180 seconds for recommendations.
- Async execution: a dedicated
ThreadPoolTaskExecutor(core 5, max 10, queue 100) handles async work. - Logging: the AI and matching services log stage boundaries ("Stage 1 completed", "Prompt length", "Groq returned", "Successfully persisted"), which makes it possible to follow a request through the pipeline.
- Failure persistence: analysis, roadmap, and note states are stored, not just logged.
15.5 What is missing operationally
- No metrics/tracing dashboard (Micrometer/OpenTelemetry would be the next step).
- No dead-letter queue policy for Kafka consumers (failures are logged and state is marked
FAILED, but there is no replay tooling). - No automated backup/restore runbook in the repository.
- No load testing or capacity plan.
These are normal for an MVP and are called out in §17.
Architecture graph
Nodes
- React SPA
- Vercel
- Render
- Security chain
- REST controllers
- STOMP and WebSocket
- SSE
- Domain modules
- Domain events
- PostgreSQL
- Redis and Cacheify
- Kafka
- Cloudinary
- Google Gemini
- Groq
Edges
- React SPA to Vercel — static hosting
- React SPA to Render — HTTPS and REST
- React SPA to Render — WSS and STOMP
- React SPA to Render — SSE
- Render to Security chain — request pipeline
- Security chain to REST controllers — rate limit and JWT
- Render to STOMP and WebSocket — real-time
- Render to SSE — streaming
- REST controllers to Domain modules — module calls
- STOMP and WebSocket to Domain modules — real-time events
- Domain modules to PostgreSQL — persistent state
- Domain modules to Redis and Cacheify — cache and limits
- Domain modules to Domain events — in-process events
- Domain events to Kafka — durable transport
- Domain modules to Cloudinary — media
- Domain modules to Google Gemini — resume parsing
- Domain modules to Groq — ranking
Key decisions and tradeoffs
This section is deliberately explicit. A credible portfolio project should show not only what works, but what is unfinished and why.
17.1 Matching and AI
| Area | Current state (verified) | Impact | Next step |
|---|---|---|---|
| Activity bonus | isUserHighlyActive returns true | Every candidate gets +5; effective threshold is 35 | Feed from analytics/todo activity |
| Completion penalty | hasLowCompletionRate returns false | No candidate is penalized | Compute from project/member history |
| Auto-match on publish | Listener intentionally disabled | Recommendations only on explicit request | Add a queue + budget guard before enabling |
| Gap-based roadmap | Code exists; CandidateBatchReadyForAiEvent producer is disabled | The UI uses the custom roadmap path; gap-based path is dormant | Re-wire the producer or remove the dead path |
| Skill-gap endpoint | Returns a stored analysis; no active producer in the current flow | May 404/500 for projects without an analysis | Connect the gap analyzer to project creation or remove from the UI |
| Recommendation read path | Per-row getUserProfile lookup | N+1 at page size | Batch profile lookup by user ID set |
| Matching prompt version | Stored as v1.0-batch-skills | Good foundation for experiments | Add evaluation harness and compare versions |
| Stale comment | "GEMINI AI" comment where Groq is used | Misleading for maintainers | Fix the comment / rename the stage |
17.2 Product and frontend
| Area | Current state (verified) | Impact | Next step |
|---|---|---|---|
Public /explore | Placeholder grid | Unauthenticated visitors see an incomplete page | Build public discovery or hide the route |
| Recommendation profile link | Opens /profile/:userId; actual route is /profiles/:userId | Broken link from the AI matchmaker | Fix the path and add a route test |
| Dashboard widgets | Some copy is static ("3 active projects") | Numbers can be wrong | Bind widgets to analytics/project queries |
| Analytics endpoint | Implemented, cached annotation present | Current dashboard does not call it | Surface metrics or remove the endpoint |
| College selection | Registration assigns the first active college | Multi-college isolation is architectural, not product-complete | Add college selection/verification |
| Profile creation | Lazy auto-create ("New User") | A user can exist without completing onboarding | Make onboarding mandatory before dashboard access |
| Hardcoded URLs | SSE host and API fallback are hardcoded | Environment portability issue | Centralize in config/env |
| Error boundaries | Not consistently used | A rendering error can blank a route | Add route-level error boundaries |
17.3 Security
| Area | Current state (verified) | Impact | Next step |
|---|---|---|---|
| Refresh tokens | Not implemented | Users re-authenticate after 24 hours | Add rotating refresh tokens |
| Token storage | localStorage | XSS can read the token | Move to httpOnly, secure, same-site cookies |
| SSE token | Query parameter; the JWT filter accepts ?token= globally | Token may appear in logs/proxies and on non-SSE routes | Short-lived stream ticket or cookie |
| JWT fallback secret | Development fallback exists | Production risk if not overridden | Fail fast when the secret is absent in production |
| Rate limiting scope | Only /api/auth/** | Other endpoints rely on auth/business limits | Add per-user limits for expensive AI endpoints |
| CORS | Restricted patterns | Good baseline | Tighten to exact production origins |
17.4 Configuration and operations
| Area | Current state (verified) | Impact | Next step |
|---|---|---|---|
| Scheduling | @Scheduled exists, but no @EnableScheduling was found | Invitation expiry may not run automatically | Enable scheduling and add a test |
| Caching | @Cacheable exists, but no @EnableCaching was found | Analytics caching is inert | Enable caching or remove the annotation |
| Kafka DLQ | No dead-letter policy | Failed events are logged/state-marked but not replayable | Add DLQ + replay tooling |
| Metrics/tracing | Logging only | Hard to diagnose latency across async stages | Micrometer + OpenTelemetry |
| Tests | 5 backend test files; no frontend/e2e suite | Regressions in UI and workflows are not caught | Add Playwright + targeted integration tests |
| Deployment | Free-tier single instance | Cold starts; no horizontal scale | Paid instance / autoscaling when needed |
17.5 Configuration gaps that are easy to miss
Two annotations are present without their enabling annotations in the source:
@ScheduledinTeamMatchingService.expireOldInvitations— Spring requires@EnableScheduling(or an equivalent configuration) to process it.@CacheableinAnalyticsService.getDashboardData— Spring requires@EnableCaching(or an equivalent configuration) to create the caching proxy.
These are exactly the kinds of issues that code review catches and runtime testing confirms. They are listed here because the document is meant to be an honest engineering audit, not a sales page.
17.6 README drift
The frontend README claims TanStack Router and describes a migration from React Router. The actual code uses React Router 7 (react-router-dom 7.18.1, Routes/Route in App.tsx). The backend README is closer to the code but is more optimistic about automatic matching and the skill-gap flow than the current implementation.
The lesson is general: documentation drifts unless it is generated from or tested against the code. For a portfolio project, the code and this case study are the reliable sources.
Hard parts
9.1 Two directions, one funnel
SkillSync matches in both directions:
- Owner → candidates: given a project and its roles, find students who can fill them.
- Student → projects: given a student's skills and achievements, find recruiting projects that fit.
The directions share the same philosophy but use different shortlist sizes: up to 50 candidates per project and up to 10 projects per student. The asymmetry reflects the economics: a project owner needs a deeper bench; a student needs a focused list.
9.2 The funnel
| Stage | Operation | Cost | Output |
|---|---|---|---|
| 0 | Authorization and ownership check | Cheap | Reject non-owners / invalid students |
| 1 | College isolation at the SQL level | Cheap | Eligible student or recruiting-project population |
| 2 | Deterministic skill-overlap shortlist | Cheap | Top 50 candidates / top 10 projects |
| 3 | Batched semantic scoring | One LLM call | Semantic score, confidence, matched skills, rationale |
| 4 | Deterministic business policy | Cheap | Final score, status, expiry |
| 5 | Persistence and read models | Cheap | Ranked recommendations with explanations |
9.3 Stage 1 — College isolation
For the owner → candidate direction, the orchestrator loads all users by collegeId, filters to STUDENT, and excludes the owner. For the student → project direction, it loads projects by collegeId and RECRUITING, and excludes projects the student owns.
This is both a product decision and a cost decision. The college is the trust boundary; it is also the first and largest reduction in the candidate set. In a single-college deployment this is a simple filter. In a multi-college deployment it becomes the tenant boundary (see §17 for the current product gap).
9.4 Stage 2 — Deterministic skill overlap
The shortlist is built with case-insensitive, bidirectional substring matching:
candidate has skill S if S contains required skill R
or R contains S
This tolerates real-world variants such as React vs React.js and Node vs Node.js. It also over-matches short tokens, which is an accepted trade-off at the shortlist stage: the LLM sees the shortlist and can correct the semantic judgement, while the deterministic stage remains cheap and transparent.
For candidates, the overlap score is:
max(overlap against the project's overall required skills,
overlap against each individual role's required skills)
Taking the maximum against a single role is important. A candidate who is an excellent fit for one role should not be penalized for lacking skills required by a different role.
The orchestrator then:
- sorts by overlap descending;
- keeps the top 50 candidates (or top 10 projects);
- fetches skills and achievements in batch using projections (
findSkillsForUsers,findAchievementsForUsers) rather than per-candidate queries; - maps the result into AI input records (
CandidateProfileForAi,ProjectProfileForAi).
If a project has no required skills at all, generation aborts with a warning rather than sending an empty prompt.
9.5 Stage 3 — Batched semantic scoring with Groq
The shortlist is sent to Groq in a single batched call using llama-3.1-70b-versatile. The prompt contains:
- the project's overall required skills;
- each role's title and required skills;
- for each candidate: skills with levels, and achievements (title, type, organization).
The model returns structured JSON:
{
"matches": [
{
"userId": "uuid",
"semanticScore": 82.5,
"confidence": "HIGH",
"matchedSkills": ["React", "TypeScript"],
"rationale": "Strong frontend overlap and relevant project evidence."
}
]
}
The prompt is versioned (v1.0-batch-skills) and the version is persisted on every recommendation. That makes it possible to compare ranking behavior across prompt changes instead of silently drifting.
Why Groq here? The matching task is high-volume, latency-sensitive classification. It benefits from a fast, inexpensive model and a single batched request. Gemini is reserved for tasks that need longer generation: resumes, curricula, mentor chat, and summaries.
Naming drift: one comment in RecommendationOrchestrator says "DEEP SEMANTIC ANALYSIS (GEMINI AI)", while the implementation calls BatchSkillGapAnalyzer and Groq. The behavior is Groq; the comment is stale. This is noted in §17 as a code-hygiene item.
9.6 Stage 4 — Deterministic business policy
The model's semantic score is not the final score. RecommendationScoringService applies a policy layer:
finalScore = clamp(semanticScore + activityBonus − completionPenalty, 0, 100)
status = finalScore ≥ 40 ? GENERATED : LOW_MATCH
expiresAt = now + 14 days
The intended policy is:
- +5 for a highly active user;
- −10 for a low project-completion rate;
- clamp to
[0, 100]; - a 40-point threshold for a usable recommendation;
- 14-day expiry to keep the feed fresh.
The persistence model stores both semanticScore and finalScore, plus confidence, matched skills, rationale, prompt version, and expiry. That separation is deliberate: it preserves the model's raw judgement while keeping the business policy auditable.
Current-state honesty: the two policy hooks are placeholders in the code — isUserHighlyActive returns true and hasLowCompletionRate returns false. In practice, every candidate receives the +5 bonus and no one receives the penalty, which effectively lowers the threshold to 35. The architecture for policy-based adjustment is in place; the data feeds are not. This is documented in §17 rather than presented as finished.
9.7 Generation is manual, and that is a feature
Recommendations are generated through explicit endpoints:
POST /api/v1/matching/dashboard/projects/{projectId}/generatePOST /api/v1/matching/dashboard/me/recommendations/generate
The listener that would auto-generate candidates when a project is published exists but is intentionally disabled (ProjectMatchmakingPreparationListener logs "AI matchmaking is disabled per user request" and the body is commented out). This avoids surprise LLM spend, avoids a thundering herd when many projects are published, and keeps the user in control of when analysis runs.
The frontend reflects the asynchronous nature of generation:
- it starts the mutation;
- shows an "AI is analyzing" state;
- polls the recommendation query every 5 seconds;
- stops after 36 attempts (180 seconds) with a timeout message;
- stops early when recommendations appear.
9.8 What the ranking system optimizes for
The design is instrumentable around a clear set of metrics:
- Precision at k: how many of the top-ranked candidates are actually invited or accepted.
- Time to team: how long a project takes to fill its roles.
- Invitation acceptance rate: whether the ranking is trusted by owners and students.
- Cost per formed team: tokens and requests divided by teams formed.
- Score calibration: whether 80% matches are accepted more often than 40% matches.
Those metrics are not yet instrumented. The point is that the architecture exposes the right seams for them: recommendation records, status transitions, invitation outcomes, and prompt versions are all persisted.
10.1 Provider routing
| Capability | Provider | Trigger | Output |
|---|---|---|---|
| Resume parsing | Gemini | Kafka resume-uploaded-topic | Summary + extracted skills with confidence and inferred level |
| Skill gap | Gemini | Skill-gap request (currently dormant producer) | Missing skills, matched skills, match percentage |
| Curriculum generation | Gemini | Custom roadmap request | Recommended role + ordered topic list |
| Topic task generation | Gemini | Roadmap creation / next-topic transition | Checkpoints with subtasks and descriptions |
| Roadmap mentor chat | Gemini + tool loop | User message | Conversational reply, optionally grounded in tools |
| Profile insights | Gemini | User action | Strengths, gaps, focus areas (JSON) |
| Note summarization | Gemini | User action | Short summary stored on the note |
| Candidate/project semantic matching | Groq | Explicit generation request | Batched JSON scores with rationale |
10.2 Why two providers
A single provider would be simpler, but the workloads are different:
- Gemini is used where the task is generative, long-context, or schema-heavy: resumes, curricula, mentor chat, summaries.
- Groq is used where the task is batch classification with a short, structured output: score these 50 candidates against this project.
Splitting providers also creates a real abstraction boundary. AiGateway, AiMatchingGateway, and AiChatGateway are interfaces; the implementations can be swapped without rewriting the domain modules. That is useful for cost experiments and for avoiding a hard dependency on one vendor's availability.
10.3 The agent loop and its safeguards
The roadmap mentor is not a single prompt. It is a bounded tool-calling loop in AiChatOrchestrator:
- Build a Gemini request with the conversation history and available tools.
- Execute the model call.
- If the response is plain text, return it.
- If the response is a function call, execute the tool and append the result to the conversation.
- Repeat.
The loop has three safeguards:
- Maximum 5 iterations. The agent cannot run indefinitely.
- 25-second wall-clock budget. Even if iterations remain, the loop returns a graceful message when time is up.
- Repeated-tool guard. If the model calls the same tool twice in a row, the loop stops with "I'm having trouble retrieving that information properly right now." This catches a common hallucination pattern where the model ignores a tool result and retries the same call.
Available tools are intentionally narrow:
get_user_todos— fetch the user's current tasks.get_latest_skill_gap— fetch the most recent skill-gap analysis.
The tools are read-only. The mentor can explain, prioritize, and encourage, but it cannot mutate the user's todo list or roadmap. That is a deliberate safety boundary: the model advises; the application owns state.
10.4 Structured-output hardening
LLM output is treated as untrusted input. The code includes several defensive layers:
| Failure mode | Handling |
|---|---|
| JSON wrapped in Markdown fences | Fences are stripped before parsing |
| Missing fields | Null-safe DTO mapping and defaults |
| Invented enum value | valueOf(...) is wrapped; falls back to LOW confidence |
| Empty provider response | Explicit error path; analysis or roadmap marked FAILED |
| Malformed roadmap steps | Parsing failure marks the roadmap FAILED and logs the cause |
| Duplicate note summarization | PROCESSING state acts as a lock; duplicate requests are rejected |
| Provider timeout | 60-second WebClient timeout; two retries with 2-second exponential backoff |
The broader lesson is that an LLM integration needs the same rigor as any external API: typed boundaries, timeouts, retries, fallbacks, and explicit failure states. A try/catch around the whole call is not enough; each item in a batch is processed defensively so one bad record does not discard the rest.
10.5 Prompt design
Prompts are centralized in PromptFactory and RecommendationPromptFactory rather than scattered across services. The important behaviors:
- The resume prompt asks for a summary and skills with confidence and inferred level.
- The curriculum prompt avoids topics the user has already completed.
- The topic-task prompt generates concrete checkpoints and subtasks for the current topic.
- The mentor prompt explicitly tells the model it cannot manually modify the todo list; the system generates the next tasks automatically.
- The batch-match prompt asks for per-candidate scores, matched skills, confidence, and rationale in a fixed JSON shape.
Centralizing prompts makes them reviewable and versionable. The matching prompt carries a version string; the others should follow the same pattern as the system matures.
10.6 Failure behavior is a product feature
When AI fails, the product does not pretend it succeeded:
- A resume analysis record moves to
FAILEDwith an error message. - A roadmap moves to
FAILEDif checkpoint generation fails. - A note summary moves to
FAILED, and the user can retry. - A matching request returns no recommendations, and the UI times out with a clear message.
This is more trustworthy than silent empty states. It also makes operational debugging possible: the failure is persisted, not just logged.
This section is the engineering core of the case study. Each item describes a real problem in the build, the root cause, the solution, and the lesson.
16.1 LLM cost would grow linearly with the user base
Problem: the naive matching design sends every student in the college to an LLM for every project. That is expensive, slow, and produces huge prompts.
Root cause: treating the model as the first filter rather than the last.
Solution: a three-stage funnel. College-scoped SQL reduces the population; deterministic skill overlap reduces it again; only the top 50 candidates (or top 10 projects) reach a single batched Groq call. The model never sees the full college.
Result: the expensive stage is bounded by a constant, not by the number of students. One request per generation instead of one request per candidate.
Lesson: the cheapest token is the one you never send. Rank before you prompt.
16.2 LLM output is not a reliable API contract
Problem: models wrap JSON in Markdown fences, omit fields, invent enum values, and occasionally return prose instead of JSON. A naive objectMapper.readValue fails the whole batch.
Root cause: treating generated text as if it were a typed API response.
Solution: defensive parsing at every boundary:
- strip ```json fences before parsing;
- map into typed records;
- null-check each field and apply defaults;
- parse enums in a try/catch with a
LOWfallback; - process each candidate independently so one bad record does not discard the batch;
- persist
FAILEDstates with error messages when the response is unusable.
Lesson: an LLM is an untrusted external dependency. Validate it like one.
16.3 AI latency does not fit an HTTP request
Problem: resume analysis and roadmap generation can take tens of seconds. Holding the request open causes timeouts and a poor UX.
Root cause: coupling a slow, retryable workflow to a synchronous user action.
Solution: move the work behind Kafka or Modulith events, persist a status record, and notify the user when it finishes. Resume analysis uses Kafka; roadmap generation persists ACTIVE or FAILED; note summarization uses a PROCESSING state; recommendations are polled by the frontend.
Lesson: asynchronous work needs a state machine, not just a background thread.
16.4 Cross-module coupling was turning the monolith into a ball of mud
Problem: services in one module needed data from another. Direct repository imports would create hidden coupling and make future extraction impossible.
Root cause: a monolith without enforced boundaries.
Solution: gateway interfaces for cross-module reads (UserProfileGateway, ProjectGateway, TodoGateway, ChatGateway, AiGateway, AiMatchingGateway) and domain events for cross-module writes. A ModulithVerificationTest runs ApplicationModules.verify() and fails the build on illegal imports.
Lesson: architecture boundaries that are not tested are suggestions.
16.5 Duplicate delivery created duplicate work
Problem: Kafka is at-least-once; Modulith listeners can redeliver; users double-click. The same event could create two todos, two memberships, or two recommendations.
Root cause: assuming exactly-once delivery in a system that does not provide it.
Solution: idempotency at the domain level:
- todo creation deduplicates by
relatedEntityId; - membership checks for an existing active member;
- role capacity is re-checked at acceptance;
- pending applications and invitations are uniqueness-checked;
- recommendations are unique on
(project_id, user_id)and upserted with optimistic locking; - note summarization uses a
PROCESSINGlock.
Lesson: idempotency belongs in the domain model, not in the message broker configuration.
16.6 Team capacity is a race condition
Problem: two students accept invitations at the same time. Both requests read "one slot left" and both insert a member. The team exceeds maxTeamSize, and the role exceeds its headcount.
Root cause: check-then-act without a transactional guarantee at the database boundary.
Solution: capacity is re-validated inside the acceptance transaction; role filledCount is incremented only after the checks; duplicate membership is rejected; the project transitions to IN_PROGRESS only when the count reaches capacity. The recommendation record is explicitly treated as a suggestion, not a reservation.
Honest caveat: without a database-level unique constraint or pessimistic lock on team membership, a true concurrent race is still theoretically possible. The next step is a unique (project_id, user_id) constraint for active members and a row lock around role capacity. The current service-level checks handle the common cases and are a clear improvement over the naive flow.
Lesson: "check then act" is only safe if the check and the act share a transaction and the database enforces the invariant.
16.7 OAuth2 tokens leaked through redirect URLs
Problem: the conventional OAuth2 flow redirects the browser with the JWT in the query string. The token ends up in browser history, referrer headers, and access logs.
Root cause: treating the redirect as a trusted channel.
Solution: the backend mints the JWT, stores it under a single-use UUID exchange code for 30 seconds, and redirects with only the code. The SPA exchanges the code server-to-server.
Lesson: a URL is a public place. Never put a long-lived credential in one.
16.8 EventSource cannot send an Authorization header
Problem: SSE is the right transport for notifications, but the browser's native EventSource does not support custom headers. The backend requires a JWT.
Root cause: a mismatch between the browser API and the stateless auth design.
Solution: the frontend appends the token as a query parameter, and the backend accepts it for the stream endpoint. The connection is authenticated and the notification stream works.
Honest caveat: query-string tokens can be logged. The proper fix is a short-lived stream ticket or a same-site cookie for the stream endpoint. This is documented as a security trade-off, not presented as ideal.
Lesson: sometimes the practical solution is a documented compromise; the important thing is to know exactly what you traded away.
16.9 WebSocket principal identity vs domain UUIDs
Problem: STOMP user destinations are addressed by principal, but the domain uses UUIDs. A mismatch causes messages to be routed to the wrong user or dropped.
Root cause: two identity representations — security principal and domain identifier — without an explicit mapping.
Solution: the STOMP interceptor validates the JWT on CONNECT and binds a principal backed by the user identity. Project chat uses project topics; direct messages use /user/queue/messages; REST history is keyed by the domain UUID.
Lesson: identity is a cross-cutting concern. Decide early which identifier the security layer and the domain layer share, and make the mapping explicit.
16.10 Render cold starts looked like outages
Problem: the free-tier backend sleeps. The first request returns 502/503, the SPA shows a network error, and users assume the product is broken.
Root cause: infrastructure behavior leaking into the product experience.
Solution: a ConnectionStatus component that probes /api/auth/ping, treats 502/503 as "waking", treats any HTTP response as "connected", and polls every 5 seconds while waking and every 30 seconds when healthy. The copy says "Waking Server..." instead of "Error."
Lesson: deployment constraints are UX constraints. Design for the infrastructure you actually have.
16.11 The agent loop could spin forever
Problem: a tool-calling model can repeatedly call the same tool, ignore the result, and loop. Without a bound, the request hangs and burns tokens.
Root cause: unbounded autonomy in an LLM agent.
Solution: a maximum of 5 iterations, a 25-second wall-clock budget, a repeated-tool guard, and graceful fallback messages. The tools are read-only, so the agent cannot mutate state even if it misbehaves.
Lesson: agentic systems need circuit breakers, not just prompts.
16.12 N+1 queries in the matching path
Problem: loading skills and achievements per candidate would produce hundreds of queries for a 50-candidate shortlist.
Root cause: mapping over entities with lazy relationships.
Solution: batch projections (findSkillsForUsers, findAchievementsForUsers) fetch all skills and achievements for the candidate ID set in one query each, then group in memory.
Honest caveat: the scoring path is batched, but the recommendation response mapping still calls getUserProfile per row. That is a remaining N+1 on the read path, acceptable at page size but worth fixing with a batch profile lookup.
Lesson: batch the hot path first; measure before optimizing the read path.
16.13 A static roadmap would stop after the first topic
Problem: generating one set of tasks for one topic creates a dead end. The user finishes the tasks and has no next step.
Root cause: treating the roadmap as a document rather than a state machine.
Solution: the roadmap stores a curriculum, a current topic, and completed topics. Each topic's checkpoints become todos. Completing all checkpoints marks the topic complete, selects the next topic, generates new tasks, and publishes a new RoadmapGeneratedEvent. Creating a new roadmap deactivates the old one and its outstanding todos.
Lesson: a learning product must model progress, not just content.
16.14 Stale AI summaries undermined trust
Problem: a note's AI summary remained visible after the note content changed, so the summary described text that no longer existed.
Root cause: treating the summary as a static field rather than a derived artifact.
Solution: editing a note clears aiSummary, resets summaryStatus to IDLE, and clears the timestamp. The next summarize action regenerates it.
Lesson: derived data must be invalidated when its source changes.
Results
18.1 Competency map
| Competency | Evidence in this project |
|---|---|
| Backend architecture | 13-module Spring Modulith monolith with gateway interfaces, domain events, and boundary verification |
| API design | 95 route mappings across 20 controllers, consistent ApiResponse envelope, DTO boundaries, pagination |
| AI engineering | Provider routing, batched inference, structured-output hardening, prompt versioning, bounded agent loops |
| Distributed workflows | Kafka ingestion, Modulith events, idempotency, status machines, async failure handling |
| Security | JWT, hashed OTP, OAuth2 exchange code, rate limiting, ownership checks, STOMP authentication |
| Real-time systems | STOMP/WebSocket chat, SSE notifications, persisted message history, read receipts |
| Data modeling | Schema-per-domain, aggregate boundaries, unique constraints, optimistic locking, projections |
| Frontend engineering | React 19 + TypeScript, feature modules, TanStack Query, Axios interceptors, accessible Radix UI |
| Deployment | Multi-stage Docker, Render/Vercel, managed Postgres/Redis/Kafka, Cloudinary, cold-start UX |
| Product thinking | Two-sided matching, explainable scores, profile completeness, learning loop, honest empty states |
18.2 Resume-ready bullets
- Designed a three-stage, cost-bounded matching pipeline (college-scoped SQL → deterministic skill-overlap shortlist → one batched Groq call) that caps model input at 50 candidates per project and 10 projects per student, with persisted scores, confidence, matched skills, rationale, prompt version, and a 14-day expiry.
- Built a 13-module Spring Modulith monolith (262 Java files, 95 route mappings) with gateway interfaces for cross-module reads, domain events for writes, and an automated
ApplicationModules.verify()test that fails the build on boundary violations. - Implemented an event-driven resume pipeline (Cloudinary signed upload → Kafka → PDFBox extraction → Gemini structured analysis → Kafka → profile skill upsert + SSE notification) with explicit
PENDING/COMPLETED/FAILEDstates. - Engineered a self-continuing learning loop where roadmap checkpoints become todos, todo completion advances the topic, and the next topic's tasks are generated automatically — with idempotent todo creation and roadmap deactivation.
- Hardened authentication with SHA-256-hashed OTPs (5-minute TTL, 45-second cooldown, constant-time comparison), a single-use 30-second OAuth2 exchange code, and 5 requests/minute/IP rate limiting backed by Redis/Bucket4j or a Cacheify HTTP store.
- Delivered real-time collaboration through STOMP/WebSocket project chat and direct messages plus persisted SSE notifications, with JWT-authenticated channels and read receipts.
- Reduced cold-start confusion on Render with a health-aware connection indicator that distinguishes
502/503wake-up from a true outage, polling at 5s/30s and using explicit "Waking Server" / "Connected" / "Server Offline" states. - Made AI failures observable by persisting
FAILEDstates with error messages for resume analysis, roadmaps, and note summaries instead of silently returning empty results.
18.3 How to talk about it in an interview
"What is the hardest part of this project?" The matching pipeline. The hard part is not calling an LLM; it is deciding what the model is allowed to decide. The architecture keeps eligibility, capacity, state, and policy in deterministic code, and uses the model only for semantic judgement. That is what makes the system affordable and explainable.
"Why a monolith?" The domain has strong transactional invariants — team capacity, role headcount, invitation state, recommendation uniqueness. A monolith keeps those invariants in one transaction and one deployable. The module boundaries and events make extraction possible later without paying the operational cost now.
"What would you do next?" Replace the scoring stubs with real activity/completion data, add refresh-token rotation and httpOnly cookies, move the SSE token to a short-lived stream ticket, add database-level constraints for membership races, and build an evaluation harness for the matching prompt.
"What are you least happy with?" The gap between the README and the code in a few places, and the dormant skill-gap path. Both are documented in §17. The honest answer is better than pretending the system is more finished than it is.
Stack
8.1 Backend
| Technology | Version | Role | Why it fits |
|---|---|---|---|
| Java | 21 | Runtime | Records, pattern matching, virtual-thread-ready ecosystem |
| Spring Boot | 4.1.0 | Application framework | Opinionated wiring, mature security/data/web support |
| Spring Modulith | 2.1.0 | Module boundaries + events | Enforces modular monolith architecture; transactional event handling |
| Spring Security | Boot-managed | AuthN/AuthZ | Filter chain, OAuth2 client, method security |
| Spring Data JPA / Hibernate | Boot-managed | Persistence | Aggregate-oriented modeling; schema generation |
| PostgreSQL | Managed | Primary database | Relational integrity, JSON columns, schema separation |
| Spring WebMVC | Boot-managed | REST API | Synchronous request/response |
| Spring WebFlux / WebClient | Boot-managed | AI provider calls | Non-blocking HTTP client with timeouts and retries |
| Spring WebSocket + STOMP | Boot-managed | Real-time chat | Topic/queue semantics, user destinations |
SSE (SseEmitter) | Boot-managed | Live notifications | One-way server push over plain HTTP |
| Apache Kafka | Managed | Durable async pipeline | Decouples resume ingestion and AI analysis; replayable |
| Redis | Managed | OTP + rate-limit state | TTL semantics, atomic counters, distributed token buckets |
| Bucket4j | 8.19.0 | Rate limiting | Token bucket over Redis; pluggable store abstraction |
| JJWT | 0.12.5 | JWT issuance/validation | Stateless authentication |
| Apache PDFBox | 3.0.1 | PDF text extraction | Resume parsing without an external service |
| Cloudinary SDK | 1.36.0 | Media storage | Direct browser uploads via signed requests |
| Spring Dotenv | 4.0.0 | Local config | Loads environment values without hardcoding |
| Lombok | Boot-managed | Boilerplate reduction | Constructors, getters, builders, logging |
| Maven | Wrapper | Build | Reproducible builds |
| Docker | Multi-stage | Packaging | Small runtime image, deployment parity |
| JUnit 5 / Modulith Test | Boot-managed | Tests | OTP unit tests, store integration tests, module verification |
Notable dependency choices:
- WebMVC + WebFlux together. The API is servlet-based; the AI calls use
WebClient. This is a deliberate hybrid: MVC keeps the CRUD surface simple, while the reactive client gives non-blocking calls, timeouts, and retry composition for slow external providers. - Redis as an interface, not a hard dependency. OTP storage, exchange codes, and rate limiting are behind
OtpStore,ExchangeCodeStore, andRateLimitStore. The default implementation uses Redis; a Cacheify HTTP implementation exists for environments where Redis is unavailable. This is a real portability decision, not an abstraction for its own sake. - Kafka alongside Modulith events. Modulith events handle in-process domain reactions; Kafka handles durable, potentially replayable AI work. Using both is a conscious trade-off between simplicity and resilience.
8.2 Frontend
| Technology | Version | Role | Why it fits |
|---|---|---|---|
| React | 19.2.0 | UI runtime | Component model, concurrent rendering |
| TypeScript | 5.8.3 | Type safety | Compile-time contracts across API and UI |
| Vite | 8.0.16 | Build/dev server | Fast HMR, small production bundles |
| Tailwind CSS | 4.2.1 | Styling | Utility-first design system with theme tokens |
| Radix UI | Multiple primitives | Accessible components | Keyboard/focus behavior handled correctly |
| shadcn-style components | Local components/ui | Design system | Owned primitives, not a black-box library |
| React Router | 7.18.1 | Routing | Nested layouts, protected routes, dynamic params |
| TanStack Query | 5.101.1 | Server state | Caching, invalidation, polling, mutation lifecycle |
| Axios | 1.18.1 | HTTP client | Interceptors for bearer token and 401 handling |
| React Hook Form | 7.71.2 | Forms | Performant form state |
| Zod | 3.24.2 | Validation | Shared schema validation on the client |
| STOMP.js | 7.3.0 | WebSocket messaging | STOMP over native WebSocket |
| Framer Motion | 13.0.0 | Motion | Page and component transitions |
| Lenis | 1.3.25 | Smooth scroll | Landing-page feel |
| Three.js / OGL | 0.185.1 / 0.0.11 | WebGL visuals | Hero and landing effects |
| Recharts | 2.15.4 | Charts | Analytics/visualization primitives |
| Sonner | 2.0.7 | Toasts | Notification and mutation feedback |
| jwt-decode | 4.0.0 | Token decoding | Client-side user identity hydration |
README drift, verified: the frontend README states that the app uses TanStack Router and even notes a migration from React Router. The actual package.json and src/App.tsx use React Router 7. The README is aspirational or stale on this point. This document follows the code.
8.3 Infrastructure and external services
| Service | Role | Operational note |
|---|---|---|
| Vercel | Static SPA hosting | vercel.json rewrites all paths to index.html for client-side routing |
| Render | Backend container hosting | Docker image; free-tier instances sleep and return 502/503 during wake-up |
| PostgreSQL | Primary datastore | Managed instance; schema-per-domain organization |
| Redis | OTP, cooldown, exchange codes, rate limiting | TTL-based state; Bucket4j distributed buckets |
| Kafka | Resume and certificate event streams | Managed broker; SSL truststore configured in the image |
| Cloudinary | Resume and avatar storage | Browser uploads via backend-signed requests |
| Google Gemini | Extraction and generation | Resume parsing, skill gap, curriculum, chat, summaries, insights |
| Groq | Batch semantic matching | llama-3.1-70b-versatile; one request per shortlist |
8.4 Test coverage, verified
The test suite is small but deliberate:
OtpServiceTest— hashing, normalization, wrong-code rejection, single-use consumption, cooldown delegation.HttpOtpStoreTest— HTTP store semantics, including 404-as-empty and TTL handling.CacheifyLiveIntegrationTest— live integration against the Cacheify store.ModulithVerificationTest— module boundary verification.SkillsyncAiApplicationTests— application context load.
There is no broad end-to-end suite and no frontend test suite. That is a real gap, documented in §17.