ADR 0015: Freeze the company logo (and name) on the application job-post snapshot¶
- Status: Proposed
- Date: 2026-06-29
- Deciders: jobhub-architect (story #244, sub-issue #255)
- Affects: application-service, notification-service, api-contracts, db/init, JobHub-ui
Context¶
Story #244 ("BAD notifications") reports two defects on the notification card:
- The job title is missing (the row shows the generic fallback label), even though the row still deep-links to the application.
- The company chip is a text-initial icon, never the real company logo.
The card is rendered by JobHub-ui NotificationIdentity.jsx via
resolveCardIdentity (notificationPresentation.js), which sets
resolved = Boolean(company && jobTitle) and renders FALLBACK_LABEL when not
resolved. Tracing the read path end to end:
- notification-service
NotificationService.enrichWithApplicationSummariessetscompanyandjobTitletogether from the resolvedApplicationSummary(ADR 0014 enrich-at-read). They cannot independently go null at this layer. - application-service
ApplicationService.resolveApplicationSummaries->resolveJob(app)buildsJobInfo(title, company, location, url)from the crawled-job snapshot. - Root cause: for crawled jobs,
resolveSnapshot(view)persists the snapshot withtitle,url,locationonly. It never setscompany, and it hashes company asnull. Worse, the gateway portJobPostGateway.JobPostView(andJobPostRemoteResponse,JobPostGatewayAdapter) only carryid, title, url, description, location: job-service'sCompanyInfo { name, logoUrl }is dropped at the application-service boundary. So the snapshot'scompanyis structurally always null for crawled applications, which makes the UIresolvedgate false and hides the (present) job title. The logo was never captured at all.
Constraints in scope: application-service and notification-service are Hexagonal; the
applied-job "copy" lives in applications.job_post_snapshot and is immutable per application
(crawled snapshots are frozen at apply time, deduped by content_hash). The DB schema is owned
by numbered db/init/*.sql; api-contracts is the single contract-first source of truth and
external/job-service models live there. job-service already exposes CompanyInfo.logoUrl
(job-service.yaml) as the source of truth for the logo.
Decision¶
We will capture both the company name and the company logo URL from job-service at apply time and freeze them on the application snapshot, then surface the logo through the existing read paths. Concretely:
- api-contracts (FROZEN in this ticket):
application-service.yamlApplicationSummaryResponse: addcompanyLogoUrl(string,format: uri,nullable: true,x-implementation-status: planned).application-service.yamlJobSummary: add the samecompanyLogoUrlso the user-facing application list/detail can also render the real logo from the same frozen source.notification-service.yamlNotificationResponse: addcompanyLogoUrl(same shape), resolved at read time alongsidecompany/jobTitle(ADR 0014 path).-
No change to the apply request contract:
createApplicationalready takes onlyjobPostIdfor crawled jobs and the service fetches the post server-side, so the logo is sourced internally via the outbound job-service client, not supplied by the client. -
db/init migration
031-applications-snapshot-company-logo.sql(number assigned below): add nullablecompany_logo_url TEXTtoapplications.job_post_snapshot. Nullable because manual entries, source posts without a logo, and pre-existing snapshots have no value. Thecontent_hashdedup key is not changed by this migration (logo is not part of identity), but see the company-name follow-up note in Consequences. -
application-service (Hexagonal, build-out by developer):
- Extend the outbound boundary to stop dropping company: add
companyNameandcompanyLogoUrltoJobPostGateway.JobPostView,JobPostRemoteResponse, and the mapping inJobPostGatewayAdapter(reading job-servicecompany.name/company.logoUrl). resolveSnapshotsets.company(view.companyName())and.companyLogoUrl(view.companyLogoUrl())on the newJobPostSnapshot(domain),JobPostSnapshotEntity(@Column(name = "company_logo_url")), andJobPostSnapshotMapper.-
JobInfogainscompanyLogoUrl;resolveJobcarries it through;ApplicationSummaryViewgainscompanyLogoUrl; the internal summaries DTO mapping andJobSummarymapping populate it. -
notification-service (Hexagonal, build-out by developer): thread
companyLogoUrlthrough theApplicationSummarydomain model + gateway adapter +NotificationResponsemapping. No schema change in the notification schema (read-through only). -
JobHub-ui (build-out by frontend developer): render the real logo image in
CoLogowhencompanyLogoUrlis present, falling back to the existing initial chip when absent. Fix theresolvedgate so a presentjobTitleis shown even whencompanyis null (the title-missing defect): the primary-label gate must not requirecompany.
The jobTitle-missing defect and the logo defect are therefore two owners: the snapshot/
resolution fix (application-service) restores company so the existing gate passes for new
applications, and the UI resolved-gate fix makes the title robust to a null company for all
(including historical) rows.
Consequences¶
- Positive: the snapshot becomes a faithful copy of the job post at apply time (company name + logo), matching its stated purpose. The logo and title render correctly without a live call back to job-service at read time. Both user-facing application views and notification cards benefit from one capture.
- Positive: the fix is backward-tolerant. The column is nullable, the contract fields are nullable/planned, and the UI degrades to the initial chip + (now) shows the title regardless of company, so historical rows with no captured logo/company still render acceptably.
- Negative / cost: a real data gap remains for already-stored snapshots: they have no company and no logo and will not be backfilled by this change. New applications are correct from the migration forward; old ones keep the initial chip. No backfill is in scope.
- Negative / cost: the outbound
JobPostViewwidening means application-service now depends onCompanyInfobeing populated by job-service; a null company there flows through as null (already handled). - Follow-ups:
- Developer build-out across application-service + notification-service + JobHub-ui per the plan above; tests: snapshot mapper unit (new column), resolveJob/summary unit, gateway adapter mapping (WireMock for the company/logo fields), notification enrich unit, and a UI test that a present jobTitle renders with a null company and that a present logo renders an image.
- Open question deferred (not blocking): whether
company_nameshould join thecontent_hashdedup key. Today company is excluded from the hash; since it was always null this was moot. Including it would split snapshots that differ only by company. Left as-is for this story to avoid churning the dedup semantics; revisit if company-only variance is observed.
Alternatives considered¶
- Resolve the logo live from job-service at notification read time (no snapshot column) — rejected: violates the snapshot's "frozen copy at apply time" contract, adds an N+1 cross- service dependency on every notification page, and breaks if the source post is deleted or the job changes employer. The whole point of the snapshot is read-time independence.
- Store only the logo, keep dropping the company name — rejected: company is the actual
root cause of the title-missing defect (the UI gate needs it, or the gate must change), and a
logo without a company name is inconsistent with
CoLogo's initial fallback. Capture both. - Fix only the UI
resolvedgate, change no backend — rejected as a complete fix: it addresses the title defect for all rows but cannot produce a real logo, which is the second requirement. The UI gate fix is kept as the historical-row safety net, paired with the backend capture for the logo. - Add the logo to the apply request body — rejected: crawled applications are created from
jobPostIdand the server already fetches the post; trusting a client-supplied logo would bypass the source of truth and invite spoofing.