86 lines
16 KiB
Markdown
86 lines
16 KiB
Markdown
# Architecture ADRs — proposed, not implemented
|
||
|
||
28 October 2026. Design baseline `4a8c907d70cbed235728f7d690b5409e72b93abe`. Authoritative architecture: [current pointer](../../architecture/current.md) → [overview](../../architecture/overview.md), [frontend](../../architecture/frontend.md), [backend](../../architecture/backend.md). The overview was originally verified September 2026 and has October updates; source inspection below resolves discovery-specific reality. Graphify was queried first; its AST scope excludes docs/config/deploy and is navigation evidence only.
|
||
|
||
## ADR-01 — extend existing owners
|
||
|
||
| Existing source owner (repository-relative) | Source-backed behavior / planned reuse |
|
||
|---|---|
|
||
| `job-tracker-ui/src/views/JobDiscoveryPage.tsx` | Current `/discover` query/location, saved searches, result sorting/dismissal. Extend with filters/list/details, not a parallel tracker. |
|
||
| `src/i18n/I18nProvider.tsx`, `translations.ts`; `JobTrackerApi/Controllers/AuthController.cs:597` | EN/Bokmål dictionaries, persisted account language, legacy normalization. Extend qualification registry and preferences, retaining API/auth/save-failure behavior. |
|
||
| `JobTrackerApi/Models/ApplicationUser.cs`; `Controllers/RulesController.cs` | Account preferences and owner settings patterns. Add discovery preference aggregate; do not reuse reminder settings for unrelated fields. |
|
||
| `Controllers/JobDiscoveryController.cs:37`; `Services/JobDiscoverySearchService.cs` | Current q/location interface; NAV request scans <=20 pages from last14days, substring filters, <=100 results, configured/public-token fallback. This is incomplete country inventory, not a reusable compliant persistent index. |
|
||
| `Models/SavedJobSearch.cs`; `Services/SavedJobSearchRunner.cs`; `SavedJobSearchAlertHostedService.cs` | Owner-scoped saved query/location, seen/dismissed IDs, initial-run baseline, periodic in-app new-result notifications. Extend rather than duplicate; add revisions/provider identity/schedules/durable delivery. Existing bare external ID cannot distinguish providers. |
|
||
| `Services/JobImport/JobImportService.cs:35`, `Plugins/FinnPlugin.cs` | Preview fetches HTML before parsing/plugins; skill tagging/optional translation. GJ04 must enforce source rights **before any fetch**, including legacy route and Add Job callers. SSRF safety is not a licence grant. No fetch executed by this research. |
|
||
| `Services/JobImport/SkillTagger.cs` | Mostly technical regex vocabulary. Preserve existing import contract; not the universal taxonomy owner. |
|
||
| `Data/JobTrackerContext.cs`; `Services/CurrentUserService.cs`; `BackgroundTenantRunner.cs` | EF context, owner filtering and scoped background processing; fail closed on absent owner. MariaDB standard; explicit SQLite compatibility retained. |
|
||
| `Models/CareerProfile.cs`; `Models/JobApplication.cs`; `Services/ApplicationAssetsService.cs` | Canonical Career Profile and separate application version domain. Discovery reads selected facts with consent; tracking and letter changes stay with existing revision/history owners. |
|
||
| `src/components/KanbanBoard.tsx`; `src/App.tsx`; `src/views/ApplicationWorkspacePage.tsx` | Preserve pipeline/routes and tracking workflow; no new automatic application state or second router. |
|
||
| `Models/UserOperation.cs`; `Services/UserOperationStore.cs`; `Controllers/AdminSystemController.cs:12`; `src/views/AdminSystemPage.tsx` | Owned recoverable operations and admin-only system surface. Integrate monitoring presentation, do not fake a user owner for source-wide ingestion. Existing maintenance dispatcher can invoke production benchmark jobs; do not reuse it as discovery sync. |
|
||
|
||
Exact proposed files below are new design names, not claims that they already exist. Before implementation re-resolve their callers/DI/current paths through Graphify and source; later accepted stages may move them. Existing theme/MUI/PageLayout/ViewStateNotice/API client/dialog/toast patterns remain owners. No new state library, AI service or UI framework.
|
||
|
||
## ADR-02 — hybrid source execution, MariaDB first
|
||
|
||
| Alternative | Benefit | Cost / decision |
|
||
|---|---|---|
|
||
| Live queries for everything | Least local storage | NAV is a feed; no complete/lifecycle-safe search. Reject. |
|
||
| Index every source | Consistent query semantics | Not all storage rights granted; extra ingestion and staleness. Reject blanket index. |
|
||
| Hybrid, source-specific | NAV feed index, SE live queries, licensed GB query or external fallback | Chosen proposal. Partial coverage explicit, common query/results contract, no new search infrastructure. |
|
||
|
||
Use EF/MariaDB indexed predicates for active inventory (country/status/modified/provider/source IDs), versioned location/occupation mapping tables and bounded text lookup. Evaluate native FULLTEXT against frozen multilingual corpus before selecting collation/token rules; fall back to bounded indexed metadata plus explicit keyword coverage, not unbounded `%term%` country scans. SQLite compatibility needs a provider-specific query implementation/test where retained; it cannot prove MariaDB behavior.
|
||
|
||
No Elasticsearch, queue broker, map SaaS or geocoder purchase. Proposed qualification load: 250,000 active source records plus 1,000,000 lifecycle events; cap ingest batches and retention. Capacity is a test target, not a measured result. If native storage fails agreed p95 gates, bring measured plans/storage cost to a separate ADR before adding infrastructure.
|
||
|
||
Cost model: database bytes per retained permitted ad × measured active count + mapping/index overhead + lifecycle retention; API calls = shared query misses + bounded detail checks + feed polls, not one full feed traversal per user/search alert. Infrastructure estimate requires measured row/index size and current operator rates. NAV/SE documented API access is free; storage/operations are not. Adzuna price is unknown pending quote. ESCO local data avoids runtime API dependency; exact artefact licence still gates import. No invented monthly total or new paid resource is authorized.
|
||
|
||
## ADR-03 — canonical contracts and ownership
|
||
|
||
All contracts are proposed. IDs and schema versions are explicit; absent source fields stay null/unknown. No mandate to create one table per concept.
|
||
|
||
| Concept | Minimum fields / invariant | Reconciliation |
|
||
|---|---|---|
|
||
| `SearchPreference` | owner, revision, schemaVersion, residence?, workCountries[], locationScopes[], occupation refs[], explicit skills[], arrangements[], advertLanguageTags[] | New aggregate linked to existing account; `UiLanguage` remains existing account field. |
|
||
| `LocationNode` | internal ID, authority/code/type/country/edition, names/aliases, validity, typed edges, geometry/point precision/licence | Public reference catalogue; [geography contract](GEOGRAPHY_DECISIONS.md). |
|
||
| `LocationScope` | rootId, refinements[], mode, radiusOrigins[], datasetVersion | Validated query value, not auto-selected descendants. |
|
||
| `OccupationConcept` | namespace/URI/version, language labels, typed related/skill edges, licence/validity | Shared public reference data; [taxonomy](CROSS_INDUSTRY_TAXONOMY.md). |
|
||
| `ProviderCapability` | rights evidence, modes/filters/content/storage/lifecycle/link/quotas, enabled/status | Registry + reviewed versioned config; secret references only. |
|
||
| `SourceAdvertisement` | canonical inventory ID; source records; title/employer; locations/precision; occupations; pay/currency/period/predicted; schedule/contract/language/requirements; description format/completeness; original/apply URLs; posted/updated/retrieved/expiry/status; provenance | External inventory; never a tenant `JobApplication`. |
|
||
| `ProviderSourceRecord` | providerId + externalId unique, content/version hash, observed revision/time, rights version, nullable fields, freshness/status | Multiple records may reference one conservative dedupe group; each retains its rights. |
|
||
| `SourceLifecycleEvent` | provider/event key, source ID/version, observedAt, operation upsert/withdraw/mask, cursor reference | Idempotent event + atomic cursor; no expired raw contact content retained by default. |
|
||
| `SavedJob` | owner + source identity unique, savedAt, own notes, optional Job/Application link, revision | New bookmark relationship; saving does not create an application. Avoid duplicating source descriptions. |
|
||
| `SavedSearch` | existing SavedJobSearch ID/owner/name; revision + schemaVersion + typed query + active state | Extend existing entity/DTO; retain legacy q/location migration and dismissals. |
|
||
| `SearchAlert` | owner/search/revision, channel/frequency/timezone, schedule, enabled, delivery idempotency/status | Extend existing alert runner + UserNotification, separate durable delivery/outbox state as needed. |
|
||
|
||
Proposed request `DiscoveryQueryV1`: workCountries, scopes, occupations + mode, keyword, skills, advertLanguages, arrangements, sourceIds, sort, cursor, pageSize. Proposed response `DiscoveryPageV1`: results, nextCursor, per-source availability/freshness/coverage/unsupportedFilters, exactness, queryFingerprint. Do not report a global total from live windows as exhaustive. Detail `AdvertisementDetailV1` returns permitted fields, completeness and provenance; inactive/rights-denied content is not leaked through an alternate endpoint.
|
||
|
||
Proposed services: `DiscoveryQueryService.SearchAsync(DiscoveryQueryV1, ownerContext, CancellationToken) -> DiscoveryPageV1`; `LocationScopeCompiler.Compile(scopes, datasetVersion) -> LocationQueryPlan`; `OccupationQueryCompiler.Compile(mode, terms, taxonomyVersion) -> OccupationQueryPlan`; `ProviderRightsPolicy.Evaluate(providerId, capability, now) -> Allowed/Blocked(reason)`; capability-specific live-search, detail and change-feed interfaces. Existing `IJobDiscoverySearchService.SearchAsync(q, location, ct)` remains a compatibility adapter until callers/tests are migrated. Proposed v2 route `/api/job-discovery/v2/search`; do not silently change old array response shape.
|
||
|
||
## ADR-04 — ingestion, dedupe and freshness
|
||
|
||
Background feed worker uses bounded pages/bytes/time/retries; allowlisted provider URLs, ETag/conditional requests where supported. Fetch outside DB transaction; commit idempotent latest source state, lifecycle event and cursor together. On crash retry same page; never advance cursor ahead of accepted deletion. Reject invalid status/schema/cursor loops, quarantine safe diagnostics and pause source; preserve last committed checkpoint. Out-of-order events cannot resurrect deleted content. Bootstrap completeness and ongoing lag are separate metrics.
|
||
|
||
Use a distinct system-owned provider cursor/lease record, not `UserOperation.OwnerUserId="system"`. Reuse bounded processing concepts, correlation/status/redaction and admin presentation; user URL-import tasks keep actual owner. Source sync stays single-writer initially under database lease; multiple backend replicas require explicit claim/fencing/expiry tests before enabling. This does not rewrite existing CV worker leasing.
|
||
|
||
Deduplicate providerId+externalId deterministically first. Cross-provider automatic grouping requires a shared authoritative requisition/canonical source identity and corroborating employer/location/time. Similar title/body at the same school/restaurant is insufficient. Uncertain pairs stay separate or reviewable. Groups retain source records and show which supplies each field; a permissive duplicate cannot expand another record's rights. Withdrawal recomputes visible alternatives and suppresses restricted fragments.
|
||
|
||
## ADR-05 — preferences, alerts and tracking integrity
|
||
|
||
Expected revision is checked at database write, including background search runs. Save a query revision/fingerprint; before publishing notifications recheck search still active, same revision, owner not deleting, source active/fresh and current rights. The first run establishes a baseline. Unique delivery key `(owner, searchId, revision, sourceIdentity, eligibleVersion, channel)` plus durable outbox avoids duplicate generation; do not claim exactly-once email delivery after ambiguous network failure. Default daily in-app digest, optional weekly/email opt-in; timezone/DST scheduling, pause/unsubscribe and bounded retries. Proposed cross-search per-owner daily digest consolidation prevents repeated copies of the same vacancy. Quiet hours and channel limits require explicit product approval.
|
||
|
||
Tracking uses existing opportunity/application creation service and owner uniqueness checks in one transaction/idempotent request. Preserve `JobApplication.Version`, Career revision, history, CV null-as-inherit, old extraction runs, CV/letter settings, frozen submitted HTML/PDF/packages and compatibility dual writes. A source update must never overwrite a user's reviewed application description, personal notes or canonical profile. If copied source content must be purged, retain user facts separately and use approved redaction/retention handling; do not rewrite frozen evidentiary documents without a separately approved lawful policy.
|
||
|
||
## ADR-06 — privacy and trust boundaries
|
||
|
||
Default private user data: optional residence, search interests/scopes/skills references, saved vacancies and subscriptions. Minimize exact home addresses: radius uses chosen public place point, not compulsory home GPS. Process matching locally from consented profile fields, not raw CV sharing with job providers. No nationality/sensitive-class relevance weighting, inferred fluency/licence or automatic application.
|
||
|
||
Maintain CSRF/auth/owner filters, rate limits and role checks server-side. Public provider inventory is separately authorized from user-owned bookmarks/searches/operations. Test cross-owner IDs and absent identities in API/worker/export/deletion. Admin lists/detail/stream/export require Admin and redact secrets/PII/prompts; log structured error code/correlation rather than raw provider payload. Propose 30-day content-free operations diagnostics, pending owner policy; source PII retention is the shortest allowed necessity/contract period, never this generic diagnostic number.
|
||
|
||
Enforce URL rights before transport, strict scheme/host validation, DNS/IP checks at connection and redirects, bounded decompress/response/pagination, no cookies/token forwarding. Strip scripts/styles/event handlers/iframes/unsafe links and remote tracking images from permitted descriptions; render plain text until safe HTML sanitizer qualification. Malicious ad text must not control tools/prompts. AI remains disconnected from core discovery and gated independently. Account export/delete must include new private state and remove queued alerts, delivery identifiers and derived private matches. Backup restore policy replays deletions/rights revocations before exposure.
|
||
|
||
## ADR-07 — additive migration and rollback
|
||
|
||
GJ-01 adds preference schema/revision without deleting columns; empty old accounts remain valid. GJ-02/03 version reference data with staged import/swap, retaining old IDs. GJ-04 creates inventory/source cursor state; GJ-06 extends SavedJobSearch query schema plus bookmarks/delivery state. Backfill old q/location as legacy keyword/explicit unresolved location, not guessed canonical IDs; preserve ID/name/active/dismissed/first-seen history. Old bare NAV IDs migrate with explicit NAV namespace and unique constraints; never silently dedupe across providers.
|
||
|
||
Test MariaDB forward migration, old-data read, concurrent writes, rollback/restart and restore. Retain SQLite transition compatibility where still supported. Roll back behavior with feature gates and previous binary while additive tables remain; do not issue destructive Down migrations or erase user-authored rows. A rollback of UI cannot roll back provider deletion obligations: lifecycle suppression/policy enforcement must continue or source content stays disabled. No production migration under this plan.
|