Що нового
123 записів, від найновішого. Це той самий журнал, який ведеться в репозиторії — розкрий запис, якщо хочеш інженерні деталі.
8 жовтня 2026
Prometheus + Grafana observability stack deployed to production and local compose
(feat/prometheus-metrics, PR #232, trackerprometheus-grafana-observability). Shipped end-to-end metrics infrastructure across the NestJS backend and Railway platform: Application telemetry (apps/etl):MetricsModuleexports a singleton Prometheus registry. ExposesGET /metrics(@SkipThrottle,X-Robots-Tag: noindex), guarded in production byMetricsAuthGuardusing constant-time SHA-256 hash comparison (crypto.timingSafeEqual) againstMETRICS_TOKEN. HTTP RED metrics recorded viaHttpMetricsInterceptorintohttp_request_duration_secondshistograms labeled by method, normalized route pattern (req.baseUrl + req.route?.path), and response status code (including properHttpExceptionstatus code extraction). Pipeline domain metrics coveringest_runs_total,ingest_records_total,ingest_duration_seconds,extraction_total,extraction_duration_seconds,extraction_cost_usd_total,dedup_merges_total,digest_sends_total, and standard Node.js process metrics (event loop lag, RSS/heap, CPU). Local infra (compose.infra.yaml): Prometheus on port 9090 and Grafana on port 3001 with declarative provisioning for instant local visibility. Railway production deployment & hardening: Dedicatedprometheusandgrafanaservices on Railway. Overcame Railway persistent volume UID 0 root mount restrictions without compromising container security: minimal entrypoints run as root for <5ms to chown storage directories and immediately drop privileges viaexec su -s /bin/sh nobody(Prometheus UID 65534) andexec su -s /bin/sh grafana(Grafana UID 472). Prometheus scrapeshttps://api.metahunt.app/metricsover HTTPS with Bearer token authentication. Grafana auto-provisions datasources directly viahttp://prometheus.railway.internal:9090and pre-loads the MetaHunt engineering health dashboard.
7 жовтня 2026
Dedup follow-up T10: centroid column dropped, dead conflicts table purged, candidate report added
(chore/dedup-t10-cleanup, trackerdedup-rebuild). Following 11 days of stable production operation without regressions or false merges, the deferred cleanup subtask T10 is complete.unique_vacancies.centroid_embeddingand the unusedexact_content_conflictstable are dropped in migration0061_curly_true_believers.sql. Group rollup (unique-vacancy-rollup.ts) no longer runs the heavyAVG(v.embedding)aggregation over 1536-dimensional vectors during sweeps. In the web feed, candidates can now flag false merges via a discrete "not the same job?" action inDuplicatesBadge, dispatching thededup_mistake_reportedevent to PostHog for operator triage. The dedup-rebuild initiative is fully closed.
25 вересня 2026
Dedup is rebuilt from pair rules; no more chains
(fix/dedup-rebuild, trackerdedup-rebuild, ADR-0016). The old resolver glued each vacancy to the best group by cosine and never revisited it. One group had 17 QA roles, another 25 Ciklum requisition numbers. Now purevetoesandisSamefeed a deterministic complete-veto clustering, and one writer serves the 5-minute sweep and a read-onlydedup plan/ atomicdedup apply. On a fresh prod copy (20 557 vacancies) the rebuild moves 3 445 vacancies, same-board violations go 1 060 → 0, and a blind-labelled 300-pair audit shows 0 false merges (the old partition: 68 of 120differentpairs merged) at 0.66 recall. Operators can detach a member (dedup_overrides), which survives every rebuild. Deployed and rolled out to prod on 2026-09-26.
21 вересня 2026
Fit reads the groups: one "A or B" now scores as one requirement
(feat/score-requirement-units, PR #224, trackerrequirement-groupsPass 2 step 7). The corpus pass had already written 2 106 groups into the database, but nothing read them —scoringCtes,recommendation.service.tsand the feed's exclusion predicate all still counted links, so every group was dead weight and Fit had not moved a point. All three collapse rows into requirement units now, through one sharedrequirementUnitKey—COALESCE(requirement_group, 'n' || node_id)— because these are the same three formulas that had to be reconciled once already (MET-144), and a third private copy is how they drift again. A unit weighsMINof its members, notMAX: weight is inverse document frequency, and "any of AWS/Azure/GCP" is satisfied by strictly more Positions than AWS alone, so its true weight is at most the lowest member's;MAXwould claim the choice is rarer than its rarest member and over-rank every grouped vacancy. An ungrouped link is a unit of one, so itsMINis its own weight and nothing about it changes.relevancecollapses too — it is the sort key on the same card as coverage, so leaving it flat would have let two of three interchangeable tools outrank one at an identical Fit.required_totalnow counts choices, so "3 of 8 required" starts telling the truth. In recommendations the bug was about to become visible: a skill whose unit the candidate already satisfies is no longer an unlock candidate at all, which is the difference between telling an AWS candidate to learn Kafka and telling them to learn Azure — the pre-change code returned Azure first. And excluding a skill stopped being able to cost you a job you could do: a Position drops only when every member of a required unit is excluded, so a user who excluded Azure keeps "AWS or Azure" postings; digests inherit it, sincesubscription-matcher.service.tsbuilds the same params. R8 came along rather than waiting for a follow-up: it had been deferred on the grounds that "the numbers are right in Pass 2", and they were not — the "X of Y required" label is computed client-side byskillDiffover the flat skill list, so a fully-covered choice would have rendered "100% · 2 of 3 required skills" over "you're missing: GitLab CI".VacancySkills.requiredcarries itsgroupnow,skillDiffreturns oneAWS / Azure / GCPchip per unmet requirement plus a unit-countedrequiredTotal, and both Fit labels read it instead of deriving their own. The card's own chip row collapses too. Fixing only the diff would have left the surface that actually gets looked at: in the warm lens each required chip is tinted by have/lacks, so a vacancy asking "AWS or Azure or GCP" showed✓ aws ✗ azure ✗ google cloudto someone holding AWS — two red crosses on a requirement they fully satisfy, next to a counter reading✗ 2. A chip carries one real skill name rather than a slash list, with the alternatives stacked behind it and dealt upward into a labelled panel on hover or focus; the front one is what the viewer already has whenever the choice is satisfied (✓ aws), so a held AWS never renders as a ✗ on two clouds they never touched. The detail page's "you're missing" list keeps the slash form — it is a text list with room to spell a choice out and no stack to hover. The cold card collapses as well: nine chips became five on the DevOps example, one per thing the job asks for.requirement-units.tsis the one grouping both the diff and the chip row read — the web twin ofrequirementUnitKey— and it sorts members by name, because a unit's names become a single label string and the API does not order its skill rows. Six integration tests plusskillDiffandrequirementUnitscases, the behavioural ones each run against the pre-change code first to confirm they fail there. Two blockers caught in adversarial audit before merge: first,RankingService.buildItemsstrippedgroupwhen mappingvacancy.skills.requiredontotoDto, so/matchcards rendered flat lists instead of collapsed choice chips; fixed by passinggroup: s.requirementGroupthrough. Second, runningGROUP BY ... COALESCE(requirement_group::text, 'n' || node_id::text)across all 188k rows exceeded Postgreswork_mem(4MB) on production, spilling 7.5 MB across 5 batches to disk and pushing unscoped scoring latency to 221 ms. Splittingunitintogrouped_unit(WHERE requirement_group IS NOT NULL, ~3% of rows) withGROUP BYandflat_unit(WHERE requirement_group IS NULL, ~97% of rows) streaming withoutGROUP BY, merged viaUNION ALL, completely eliminated disk spill (0 bytes, 1 batch, 1 MB RAM) and brought prod latency to 173 ms with 100.000% score parity across 15 937 active positions.FIT_STRONG_MIN/FIT_GOOD_MINare uncalibrated from this commit — the denominator shifted corpus-wide — and re-setting them waits on the remaining 82% of the corpus, per the tracker's step 6.One requirement is one
anyOflist, and the corpus was re-extracted to prove it(feat/skills-anyof-contract, PR #222, evidence in PR #223, trackerrequirement-groups).Skills.required/optionalare now lists of requirements carrying ananyOfeach; thealternativesoverlay shipped the day before is gone. The overlay asked the model to restate, as pairs, names it had already written into a flat list — a second pass over a finished answer, andalternatives: []is schema-valid, so skipping it failed silently. Measured on the 29-row golden set, three passes per change: production grouped 5 of 23 binary choices under the overlay, 10 with the reshaped contract, and 15 once a slash-pair rule was added — past both the 12 theanyOf-native eval contract reached and the 12 a reasoning challenger reached at 38× the latency.or_split_errors21.3 → 6.7. Three of the loader's four drop reasons became unreachable and were deleted with their tests: members come fromrequiredby construction, so none can be unknown or optional, and a choice aliasing onto one node is an ordinary flat requirement — onlyoverlappingsurvives. Two bugs surfaced that had nothing to do with the reshape and everything to do with nobody having run the path.CachedVacancyExtractor— whatVACANCY_EXTRACTORactually resolves to — had noidentity(), soStalePostingsActivitycould not ask for the currentspecHashandreextractWorkflowdied on its first activity; it had never completed a batch since it shipped. It type-checked becauseuseExistingdoes not check the provider against the token's type and the class declared noimplements. And the reshaped loader read.anyOfoff each stored entry, which on the 26 833 rows written as flat strings isundefined→ a vacancy stored with zero skills, no throw, no warning; re-extraction is not a fix for that, because the stale query joinsunique_vacanciesand a duplicate's record keeps flat names for good, so the loader reads both shapes permanently. Then the corpus pass (Pass 2 step 5): 2 959 canonical postings — the last month — re-extracted on prod in 88 minutes for ≈$1 billed, zero failed artifacts, after a verified 332 MB dump taken through the Postgres container (the public proxy is unreachable from the operator's machine, sodb-backup.shcould not be used). Inside the window: required links 21 534 → 28 778, postings carrying no skills at all 53 → 13, 2 106 groups over 42% of postings (mean size 2.51, max 9 — all inside the §8.2 bands), and Jenkins coverage 71.1% → 93.0%, the skill this tracker was named after, whose gap sat in "ci/cd tools (jenkins, github actions, etc.)". All fifteen coverage probes improved, none regressed. Five groups were read back against their postings' own sentences — including two Ukrainian slash pairs (Ansible або SaltStack,Redis/Valkey) that had never grouped before, and the nine-member QA one that a size cap would have broken. The pass also created an open item: 271 nodes minted, roughly one in eight not a skill —Backend Engineer,Analytics Engineer,Solutions ArchitectandPL/SQL Developernow exist asSKILL:NEWbeside their ownROLE:VERIFIEDnodes, plus duties, metrics and certifications.node_statscountsNEW, so they enter IDF atdf = 1, the highest weight band — which is the cost R10 was measured against and the argument for keeping it detached. Fit did not move and could not:scoringCtes,recommendation.service.tsand the exclusion predicate still do not readrequirement_group. That is step 7.The golden set was rebalanced to the corpus
(chore/golden-set-rebalance, PR #220, R9). 29 rows with 32 MUST groups and a fresh baseline; the FinOps row that carried 23 of 150 misses on its own is gone, and the R7 release gate is 16 rows rather than 9.
17 вересня 2026
Feed homepage + one subscribe control shipped, and subscription editing unlocked for everyone
(feat/feed-homepage, PR #213, squash-merged asb1e9aa4). The headline change under the hood:PATCH /me/subscriptions/:idno longer refuses a filter (non-CV) subscription's criteria — the admin-only edit gate, the frontend'ssubscription.isCv &&guard, and the backend'scandidateId === null → 400all dated from when a CV subscription was the primary kind; 46 of 51 prod subscriptions are plain filters now. Editing writes straight over the row's identity, so it gained the same collision checkcreate()always had (409+ the conflicting subscription's id/name).The query/filter types were the blocker for seeing this clearly. Three disagreeing definitions of "what a subscription is" — a hand-
Omit'dListVacanciesQuery, a 26-keySUBSCRIPTION_PARAM_KEYSwith dead singular/plural duplicates, and the 13-key realitytoSubscriptionParams()actually wrote — collapsed into composable axes:VacancyFilter & SkillFilter & FreshnessFilter & Pagination & Ranking & FeedBrowsingon the frontend (lib/api/filters.ts), mirrored on the backend viaIntersectionType(platform/shared/filter-params.dto.ts).SubscriptionFilter = VacancyFilter & SkillFilter— noOmit, what a subscription lacks is now absent by composition.SubscriptionParamsis typed off that DTO instead ofPartial<Record<K, unknown>>, which is what surfaced every place still reading a field no longer persisted.Fixed on the way:
skillIdshad no writer in the editor's old codec, so editing a subscription's skills silently saved the old ones back (now one pair,filterToState/stateToFilter). The stored filter droppedpostedWithinDays(leaked in from the feed's default freshness on 50 of 51 prod rows — nobody chose it, and being part of the dedup identity, it split one alert into two) andminFitTier(a no-op without a scorer that still changed the identity) — migration0057strips both from existing rows; checked prod first, zero rows differ only by these keys, so nothing merges. A saved role/skill/domain with no vacancies right now used to render as a bare slug in the editor (the catalogs are an INNER JOIN from live positions);GET /me/subscriptionsnow shipsrefNames, resolved from the taxonomy, and the editor labels from that instead.Also from the earlier commits on this branch (
211c306,ff3db73): a subscription is now conceptually a filter, not a CV — CV subscriptions still run but the UI no longer creates them. Plus three latent-but-real fixes surfaced by a targeted review: idempotency and/mestatus derivation both keyed "pending" offlinkedAt/deactivatedReasonbeing null, which also matches legacy rows predating those columns (now keyed onchatId, the unambiguous signal); array order insideroleIdsetc. wasn't normalized before the jsonb dedup comparison (now sorted); a freshly uploaded CV could vanish from the switcher if the server refetch lagged past a fixed 2-minute window (now gated on the query's actualdataUpdatedAt).Known, deferred, not blocking:
q(free-text search) is silently dropped when subscribing — a subscription can end up broader than the search it was created from. CV subscriptions' skill filter is decorative —paramsToCandidateCriterianever readsskillIds, since CV matching ranks against the resume's own skills instead. The taxonomy carries junk/misnamed role nodes (cloud-architect,platform-engineer, …) surfaced by this work but out of scope for it — cleanup is a separate pass. No tracker; verified via full local unit + integration suites (real Postgres) plus a live post-deploy check against prod (migration applied cleanly, 0 rows retain the dropped keys, 0 live duplicate pairs, subscription count unchanged, both Railway and Vercel deploys green on the merge commit).
15 вересня 2026
Analytics-page funnel repaired, and the drift it exposed closed
(fix/analytics-page-funnel)./dashboard/analytics's Funnel widget had been silently reporting 0 for "Started subscription" and "Opened handoff" for a month: PR #187 (2026-08-17) deleted the browser producers forsubscription_create_started/subscription_handoff_opened/landing_cta_clicked, but the dashboard's HogQL query kept filtering on those dead event names. The funnel is now 3 live steps (visited → started → linked, backed by$pageview/subscription_created/telegram_linked); the deadctaClicksstat is gone.scripts/analytics-catalog.tsgained a fourth check — it now reads the funnel query's ownevent = '...'literals and fails the guard if one has no live producer, closing the exact blind spot that let this ship unnoticed (the guard previously only cross-checked the event registries against each other, never a consumer).md/runbook/first-user-funnel.mdgot a stale-banner pointing at the live event catalog instead of a rewrite. Also: the Contacts and Subscribers/Delivery tables got real column separators (divide-x) so adjacent columns stop reading as merged, and every non-obvious number on the page (DAU/WAU/MAU windows, source attribution, lifecycle-state definitions, delivery/roster window rules) now carries an inlineHintTexttooltip — one shared component instead of the two ad-hoc copies that first shipped it (a code-review pass caught both the duplication and an aria-label collision it caused across sibling stat cards). No migration.
26 серпня 2026
The vacancy page gets a grid, and the description becomes readable on a phone
(feat/vacancy-page-layout)./vacancy/[slug]had been onemax-w-[880px] flex-colstack with all 433 lines inline — the narrowest page in the app, againstHubShell's 1080 andPageBody's 1400 — so it read as a mobile layout stretched onto a desktop viewport. It is now a full-width hero over one placed grid:[248px | 1fr | 336px]atxl, two columns atlgwhere the spec rail spans both rows so the bottom-left gutter is not empty, one column below that. One DOM tree, not two. The spec rail reflows from a 2-up/3-up boxed grid into a bordered spec sheet in CSS rather than rendering twice behindhidden, because the second copy would double the crawlable text on ~4.9k pages. The similar-vacancy list was throwing away data it already had:loadSimilarreturns fullVacancyDtos and the old markup rendered a role name and a company name, so the cards now carry seniority, salary and three skill chips at no extra fetch — and those links are the only internal path between vacancy pages, so they are a crawl surface, not decoration. The mobile description was ~30 characters per line: 16px/1.7 inside ap-6card inside apx-6section leaves 279px on a 375px screen..vacancy-bodyis mobile-first now (15px/1.65, 16px/1.7 fromsm) and the card goes full-bleed belowsm, which buys back the 32px that separates ~41 characters from ~45. Two pre-existing bugs surfaced while working in that block: the rules used direct-child combinators, so a<p>inside a<blockquote>got no margins at all — and widening them to descendants then required:last-childto stop being child-only, or a description closing on a blockquote gained 1em of dead space. Deliberately unchanged:force-static+revalidate = 900and both JSON-LD blocks. The page renders identically for everyone, and a single cookie read would make it dynamic and uncacheable again.docker compose watchis a foreground process, andrestart: unless-stoppedwas resurrecting the containers without it(feat/vacancy-page-layout). The web container had been up ten hours serving code frozen at image-build time, with nothing in the logs to say so — an edit-on-host, refresh-in-browser loop that silently did nothing. The fix is to stop needing the watcher for the app being edited:apps/webis bind-mounted now, sodocker compose up -dis enough and a stale web container is not a reachable state. etl keepssync+restartbecause it is a stateful poller that genuinely needs a container restart per change. Both app services drop torestart: "no", so a dev container being up means someone started it this session. Three things had to be true for that to work. The anonymous.nextvolume landed root-owned — the sidecar ignore file drops.nextfrom the build context, so Compose seeded the volume from nothing andnext dev, running asnode, could not write its own build dir; the image creates the directory itself. Compose reuses anonymous volumes across a container recreate (verified: a plain--force-recreatereturns the same volume ids,-Vreturns new ones), so after a dependency change the stalenode_modulesmask would have shadowed the fresh install and defeated thepnpm-lock.yamlrebuild rule — both scripts pass-V. Anddepends_on: - etlonly waits for the container to exist, so the first page load raced etl's ~30s boot and 500'd onECONNREFUSED; there is a healthcheck now, gating onGET /(process up +SELECT 1) rather than/healthz, which also demands Temporal and object storage and would abort the wholeupon a slow Temporal — on the path the runbook recommends for frontend work.
25 серпня 2026
The Postgres analytics ledger is gone; PostHog is the only analytics system
(refactor/analytics-one-identity, trackeranalytics-one-identity). Phase 4, in the order the brief demanded: rewrite, prove, delete, drop. The scope was not "delete two tables". 14 ofproduct-analytics.service.ts's 15 methods readproduct_eventsoranalytics_journeys, most through raw SQL rather than the drizzle objects — grepping forproductEventsunder-reports it by a wide margin. The roster (people,subscriberActivity,subscriberStates) moved to PostHog keyed on the person; delivery (deliverySummary,deliveryDaily) moved tosent_notifications, the domain record of what we actually sent;orderedFunnel,channels,retention,growth,feedEngagement,periodFlow,recentJourneys,identityHealthandupdateJourneywere deleted with their panels. Every rewritten query was measured against the one it replaced, on production, while the ledger still existed — a comparison that is free before the drop and impossible after. Clicks: 51 of 51 people identical, per person, feed and digest alike. Subscriber states: 32 of 32 chats, on every dormancy window that lies inside the covered era. Delivery: 2,540 of 2,541 sends, with both sides of the residue read row by row — 6 ledger rows from day one with a nullsubscription_id, and 1 send whose message failed after the vacancy rows were written.telegramLinkedAtneeded no analytics store at all:subscriptions.linked_atmatched all 40 events within 5 seconds and covers one subscription more. A digest is an hour, not a row.sent_notificationsholds one row per vacancy, so counting rows overstates digests 2–4×; grouping on(subscription_id, date_trunc('hour', sent_at))reproducesdigest_sentexactly, because the schedule is hourly. Three fields had no source left and were named rather than quietly nulled:ctaClickedAtandfirstSeenAtare deleted (producers dead since July, rendered nowhere, andjoinedAtanswers the second honestly),sourcewas rewritten on PostHog's$referring_domainbecause it is rendered. The plan said "retarget the outbox at PostHog"; the same step dropsanalytics_outbox, and both cannot be true. The code settled it — every PostHog verb was already captured directly by the domain service that owns the act, so retargeting the dispatcher would have double-counted every digest, which is precisely what the "one author per store" decision had warned about. The single forwarder was alreadyPostHogClient; the outbox retired with the table it drained into.?j=survives the drop:subscriptions.journey_idkeeps its values and loses only its foreign key, and where a journey names no subscription the click falls back to the journey id itself — the same valueanalytics_journeys.person_idheld (260 of 267 rows), with PostHog's claim-time alias covering the rest.redirect.controller.tswas not touched; the crawler gap stays deliberately open. The lesson worth keeping: the window in which two stores can be compared closes the moment one is deleted, so the proof has to be the first step, not the last.Two things that were quietly untrue
(chore/drop-lying-surface-and-failure-kinds, PR #196).vacancy_outbound_unattributed, one day old, still carriedsurface: "web_feed"— inherited from the branch it forked out of. A tap with neither?s=nor?j=gives us nothing to infer a surface from, so the field stated as fact the one thing the event exists to say we do not know. Gone; the spec now asserts its absence, becauseexpect.objectContainingignores extra keys and would have passed either way — an assertion that cannot fail is not coverage. The second was dead weight with a deadline.ProductDeliveryHealth.failures(chat_unreachable vs transient) had its own CTE indeliverySummary, a contract interface, and a mirrored type in the web API layer — and no component rendered it. The only thing asserting on it was one line in an integration spec, which is how something unread stays green for weeks. It was also the single part of the Delivery panel with no domain table to move to when the ledger retires (digest_delivery_failedlives only there), so it was already booked as an open decision in the phase-4 brief. Deleting it now removes that decision from the migration instead of carrying it in.digest.service.tsstill emits the event; only the aggregation nobody read is gone. Worth naming: both survived review because they were plausible — a surface field on a click event and a failure breakdown on a delivery panel are exactly what you would expect to find, which is why nobody checked whether either was true or used.
24 серпня 2026
The click that could not be named gets its own verb, and the parity gate turns out to have been unpassable
(chore/unattributed-click-event, PR #193, trackeranalytics-one-identity). #189 gave the unattributable outbound tap anis_anonymous: trueflag so it could not pass for a person. That protected per-person metrics and nothing else — and the volume turned out to be the whole story: unattributable taps outnumber attributable ones ~30:1 (08-23: 68 against 2, i.e. 97% of all/gotraffic), sovacancy_outbound_clickedwas meaningless to anyone who forgot the filter. A flag you must remember is not a safeguard. The fallback branch now emitsvacancy_outbound_unattributed; the flag rides along for continuity with rows already ingested under the shared name. The same asymmetry is why the ledger parity gate had never produced a readable answer.product_events.journey_idis aNOT NULLforeign key, so a/gotap with neither?s=nor?j=cannot be written to Postgres at all —applyClicked()sends it to PostHog and returns. The runbook asked to compare rawsurface=web_feedagainstapply_clicked, which could never have matched on any day, however healthy both stores were; the row was mis-specified, not the data. On the attributed subset PostHog reproduces the ledger exactly. Gate closed: 08-18 → 08-23, six consecutive full days at 0.0% divergence across digests, digest clicks, feed clicks and activations — identical event for event, not merely inside the 5% tolerance. 08-17 is excluded rather than failed: the two agree per hour from 15:00 Kyiv (13, 12, 9, 14, 13, 10, 4) and diverge before it, and #187 deployed at 15:22 +0300 — a window cannot count the day the code that closes it shipped. Owner waived the seventh day. Phase 4 (droppingproduct_events+analytics_outbox) is unblocked and deliberately not started. One theory ruled out along the way: the unattributed volume is not pre-cutover users PostHog fails to recognise.ApplyLink.tsxcallsgetOrCreateJourneyId()— create — so a journey id is minted on first render of a feed card for any browser, whenever the account was made; a real feed tap always carries?j=, a digest tap always?s=, and a hit with neither is not a returning human. The crawler gap stays open on purpose:redirect.controller.tspasses an absentSec-Fetch-Modeas human so Telegram in-app taps survive, which is the hole a browser-UA crawler walks through. The rule this establishes: when a metric needs a filter to be true, put the distinction in the event name — and when a verification gate never fails and never passes, suspect the gate before the system.The
seoworkflow has been auditing railway.com since July(fix/seo-audit-vercel-only, PR #194). It triggers ondeployment_status, and both providers send those — so every ETL deploy handedseo-audit.tsthe Railway dashboard URL and got 32 failures: no sitemap,lang="en", noh1, no canonical. All true of railway.com, none of them about our site. Each deploy has produced one green run and one red one since 2026-07-27, which is long enough that a redseoonmainhad stopped meaning anything. The guard that should have caught it was disabled by an unrelated change:[ "$DEPLOY_ENV" != "Production" ] && [ -z "$BYPASS_SECRET" ]is an AND, so addingVERCEL_AUTOMATION_BYPASS_SECRETto audit protected previews made the second half permanently false and switched off the skip for every non-Vercel deployment at the same moment. Two intentions — "previews are protected" and "this deployment is not ours" — were entangled in one condition, and satisfying the first silently repealed the second. The job now gates ondeployment.creator.login == 'vercel[bot]', the field that actually distinguishes them (Railway sendsrailway-app[bot], environmentmetahunt / production).
17 серпня 2026
Two things the analytics cutover only revealed in production
(fix/analytics-outbox-drain-lockPR #188,fix/anonymous-click-identityPR #189). First: the drain query gained aLEFT JOIN subscriptionsso a ledger event lands on the subscription's person, but kept its bareFOR UPDATE SKIP LOCKED— Postgres refuses to lock the nullable side of an outer join, so every dispatch failed every 5s from the moment #187 deployed. Nothing was lost (rows stay pending and retry), but the ledger stopped materializing.FOR UPDATE OF outboxlocks only the rows being claimed. The unit suite could not have caught it — row locks and outer joins do not survive a mock, and the existing ledger tests seededproduct_eventsdirectly, sodrain()had no integration coverage at all; it has three tests now. Second, and the more interesting one: the outbound-click fallback captured with arandomUUID()when a click has neither subscription nor journey. That code was old, but harmless while it wrote to a dormant archive client — pointing the sink at the live project turned it into 15 synthetic "people" in three hours, and the unique clickers tile read 15 for the day against 0–3 on every previous day. The click volume is real signal that was previously lost; the identity is not, so the event now carriesis_anonymous: trueand the per-person tile excludes it. The rule both share: a change is not verified by a green deploy. The first was found by reading the logs afterSUCCESS, the second by asking whether a number that moved in the right direction moved for the right reason.
16 серпня 2026
The analytics migration that stopped at step 2 is finished, and PostHog now sees the people it was blind to
(refactor/analytics-one-identity, trackeranalytics-one-identity). Three read-only audits found one rule behind most of it: "an unlinked subscription is not yet a person." Server captures skipped anything without ausers.id, so PostHog saw 202 of 977 digests (21%) in the 4–14 Aug window and 15 of the 18 people who clicked a digest link were invisible to it — while the fix,subscriptions.person_id, was already populated 45/45 and simply unused. Identity is now that column everywhere; an account claim aliases the pre-account person into the account, which is the one moment PostHog can still merge them. The browser was muted in three separate ways: four acquisition events were handed to a stub whose entire body wasreturn;, an allow-list of one name ($pageview) discarded seventeen more on the last line before the SDK, and pageviews were switched off and guarded behindidentify(), so 0 of 78 sessions in eleven days began with a pageview. Pageviews are back on history changes, the stub and the allow-list are gone, and the events that survived Rule 4 — outcomes an outsider watching URLs and clicks could not infer — are the only custom ones left; the rest is$pageviewplus autocapture. A security fix rides along:?cv=was stripped from$current_urland$referrerbut not from$initial_current_url/$initial_referrer, which posthog-js writes with$set_once— a bearer capability parked on the person profile permanently. Structural cleanups: three PostHog clients became one (PostHogClient), the dormant archive sink is deleted, the outbox writes Postgres only so no fact has two authors,telegram_linkedandsubscription_createdfinally have emitters after being queried six times and emitted zero,CreateSubscriptionRequest.journeyIdis read at last, so the anonymous web journey merges into the subscriber's person instead of staying a stranger, outbound clicks carry vacancy/source/company, andpnpm analytics:catalognow verifies reachability — it fails on a documented event with no emitter, which is exactly how the no-op stub survived a check reporting "34 events documented". In PostHog: the starter dashboard and its eight pageview-based insights are gone, six replay playlists for a disabled feature are archived, authorized URLs are set, the 4 Aug cutover is annotated, the no-op filter test accounts toggle is off on the three real insights, and a daily alert fires if a whole day passes with nodigest_sent— vacancy clicks went quiet for two and a half days this month and nothing noticed. Deliberately not done: retiring the ledger. The cutover doc pencilled 18 Aug; the condition is seven consecutive days of PostHog reproducing its counts within 5%, and that window cannot start until this deploys.
15 серпня 2026
An empty company slug becomes impossible, and the repair that proved it necessary is already gone
(fix/company-slug-recovery, PR #186).companies.slugis the resolve-or-create key the loader looks an employer up by, so an empty one silently merges unrelated employers into a single row — and the old resolver stripped non-ASCII with no fallback, which collapsed every Cyrillic-only employer at once. A 2026-08 restore still showed it: one row namedЛУНholding 264 vacancies belonging to 97 real employers (ПриватБанк 42, Мрія 13, Дія 11, Міністерство оборони України 10). Migration0051addsCHECK (slug ~ '^[a-z0-9][a-z0-9-]$'). The interesting part is what it replaced. A one-shot CLI was written to repair the collapse from each vacancy's authoritativeextracted_data.companyName, and it did repair the local restore — 1,854 → 1,945 companies, 264 vacancies moved, second run idempotent. Then production was checked before running it there: 0 unsafe slugs across 2,628 companies, with every collapsed employer already living as its own correctly-slugged row (ПриватБанк 68, Мрія 15, ЛУН 7). The fixedslugifyCompanyhad re-resolved them on ingest; the corrupt state only ever survived in a restore taken before that deploy. So the script had no second database to run on and was deleted in the same PR rather than left to rot — the durable artefacts are the constraint and this entry, not the code. Two smaller findings came out of the dry run and are recorded rather than silently fixed: Djinni'sне вказано("employer not stated") would have been minted as a company that every future unattributed posting resolved to, and recovery restores source names, soПриватБанк/Приват БанкandМОУ/Міністерство оборони Україниremain separate rows — company deduplication is its own problem. The obsolete regression test"still reports the employer when a legacy row has an empty slug"went too: the constraint makes that state unreachable, and keeping the test would have meant keeping the bug reachable in order to exercise it. Rule this establishes for data repairs: ask what invariant was missing, land that* as a migration, and treat the repair script as disposable with its deletion inside the same task.
14 серпня 2026
The hourly in-database market snapshot is deleted
(refactor/drop-market-snapshots, PR #183).CaptureMarketSnapshotActivitycopied the whole market into Postgres on every hourly ingest —to_jsonb(p)over ~13k positions, TOASTing to 2.2 GB, plus all ~130kposition_nodes. 68 MB per run, no retention, and no reader outsidepositions.int.spec.ts. It reached 3800 MB of a 4462 MB database (85%) in 56 snapshots over three days and filled the volume:PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp": No space left on device, checkpointer aborted, all server processes reinitialized. Production was down ~28 hours (13 Aug 09:00 → 14 Aug 13:03 UTC). The design lesson is the invisibility, not the disk: the capture sat inside atry/catchthat logged a warning, sorss_ingestsreportedcompletedfor six hours after inserts had already begun failing on a full disk — a swallowed exception turned a hard failure into a silent one. Lost: ~400rss_recordsthat aged out of the source feed windows before recovery. Not lost:analytics_outboxdrained fully (0 pending). This reverts PR #178 five days after it landed, and replaces #182, which bundled the fix with a hand-rolled backup system that does not belong in the app. Snapshot-shaped features return only with retention, a reader, and a size budget stated up front.
9 серпня 2026
Skill co-occurrence rebuilt at position grain, with its evidence visible
(feat/nodecooc, PR #174).node_skill_cooccounted source postings, so a repost could vote more than once, and it admitted unverified and optional skill links — then fed that inflated table straight into user-facing CV suggestions. Now one canonical position contributes one observation, only VERIFIED + REQUIRED links count, and conservative floors apply (skill support ≥25, pair support ≥10). Duplicate directional rows collapse into one unordered pair carrying support, both conditional probabilities, lift and NPMI — a normalized score with no denominator beside it is unauditable. The ranking substitute gate additionally requires 25 observed positions. Refresh is now concurrent with both endpoint lookups indexed. Verified on a disposable restore: the rebuilt view and a freshly rerun lab pipeline produced exactly 4,139 eligible pairs with zero mismatches.Scoring follows the read model to canonical positions
(feat/met-139-scoring-cutover, MET-139, PR #177). The deliberate posting-grain exemptions #175 left behind — node statistics, ranking, recommendations, role suggestions, match telemetry — move to Position /position_nodesreads, andnode_statsis rebuilt at position grain. Split from #175 on purpose so a ranking regression could be attributed to the calibration change rather than to the grain change. Closed after the scheduled 03:00 UTC production refresh was observed green.Market snapshots
(feat/met-140-market-snapshots, PR #178) — append-only position/node snapshot tables captured after each market refresh, immutability enforced by triggers. Reverted five days later; see 2026-08-14.The track band becomes an interactive market map
(feat/track-picker-ui, PR #179). A compact picker sits between hero and feed: clicking a track reveals its child tracks and a direct feed CTA, clicking the active tile clears the selection.HowItWorksstays below the feed.A track badge now predicts its own click
(maxikfabin/met-141-track-required-skill-count, MET-141, PR #180).track_countscounted a Position when a track's skill was linked at all, optional included — but the feed's default filter is required-only, so every skill-carrying track overstated what clicking it would show (backend-go 304 vs 222, frontend-react 337 vs 305, devops-aws 678 vs 539). AddingAND pn.is_requiredto the view's skill predicate makes the badge and the destination agree. Left open on purpose and documented in the view's comment: a track matches ANY of its skills while the feed requires ALL — identical today only because no track carries 2+ skills.Hero counter copy
"jobs indexed" → "jobs tracked" (PR #181) — reads clearer beside the live pulse dot and "updated Xm ago".Position becomes an object, not a convention every query re-derives
(feat/positions, MET-137 / MET-138). ADR-0012 declared Position the public grain a year of queries ago, but never gave consumers something to read — so five call sites each rebuilt the collapse differently: a window function in the feed,count(DISTINCT coalesce(...))in company facets, no collapse at all in the market aggregate, and a hand-writtenunique_vacancies → canonical_vacancy_id → vacancy_nodesjoin innode_skill_cooc. On the 2026-08-07 restore that gap is 15,101 postings against 12,773 positions: anything that forgets to collapse overstates the market by ~18%. Three curated regular views now carry the definition —postings(one source observation, explicit columns, noSELECT, no embeddings or dedup internals),positions(one row per group: canonical facts +representative_posting_idas a display pointer + group freshness/counts),position_nodes(canonical taxonomy links, required and optional). Feed, market, facets, tracks, contextual skills,track_countsand the Lab population all read them;coalesce(unique_vacancy_id, id)is gone. Two rules keep it honest: canonical supplies facts so a repost from a second board can never change what a Position is, only which card is freshest; and the views contain every row, leaving eligibility (ELIGIBLE_POSITION), VERIFIED gates and time windows as explicit consumer rules. Verified: feed total = market total = role-facet sum = 12,456 eligible positions, andnode_skill_cooccame out byte-identical — 4,143 pairs, zero diff across all 11 columns — so the rewire is provably a refactor, not a metric change. Public aggregates now stateunit: positions,asOfandwindow; per-source volume saysunit: source_postingsand must not be summed against the total. One deliberate behaviour change:?sourceId=now means "this Position has a posting on that source" and still shows the Position's real representative, instead of re-picking a filter-scoped member — the old fallback made display depend on the filter, which no aggregate could reconcile. Calibration-sensitive scoring (IDF, recommendation cohorts, role suggestions, match telemetry) deliberately stays posting-grain until MET-139 can measure before/after and retune thresholds; changing grain and calibration together would leave no way to attribute a regression. Every such read carries aPOSTING-GRAIN-EXEMPTcomment and a guard test fails any new* raw aggregate that lacks one. Additive migrations only (0044–0046), no physical rename, no raw data deleted — rollback is a forward view fix, never a restore. → ADR-0015, tracker
8 серпня 2026
A posting always has a position group — enforced by the database, not by luck
(feat/canonical-vacancy-grain→fix/reconcile-position-rollups→feat/deferred-position-fks→maxikfabin/met-128-require-position-group, MET-128, PRs #165 #166 #167 #169).unique_vacancy_idhad been carrying two meanings at once — position identity and dedup pipeline state — which forced product consumers to reconstruct a position withcoalesce(...)and let two different writers compute incompatible group rollups. Landed as four deliberately separate phases so each could be verified against production before the next: 1a/1b gives every newly loaded posting a singleton group immediately, moves the dedup queue todeduplicated_at IS NULL, preserves membership across content updates, and routes every rollup through one shared writer; 1b.1 reconciles the historical drift the two-writer era left behind (863 groups with stalelast_seen_at, 853 also stale onfirst_seen_at); 1c.0 makes the creation FKsDEFERRABLE INITIALLY DEFERREDand has the loader mint both UUIDs inside one transaction — without it the contract migration would reject every new posting, because a vacancy points at its group while the group requires a canonical vacancy; 1c.1 finally setsNOT NULL. Production was re-checked read-only immediately before the contract landed: 15,090 postings, 0 ungrouped, 0 pending, 12,764 positions, 0 missing representatives, 0 canonicals outside their own group, 0 stale counters.VacancyUpsertValuesnow omits both ids so callers cannot supply half a pair, and six integration suites stopped hand-building group rows — the old fixtures invented their own counters, so a test could assert a shape production never produces. → trackerThe skill graph gets its own app, deliberately outside the product
(maxikfabin/met-129-lab, MET-129, PR #171). A standalone Vite + React app plus its SQL pipeline, replacing #170's route-behind-auth packaging. The isolation is the script naming: everything islab,lab:build,lab:check,lab:dataand nothing is calleddev/build/lint/test, sopnpm dev,pnpm lintandpnpm build:allskip the package with zero config exclusions to keep in sync — renaming a script to a standard name silently undoes it. No runtime database dependency: the app reads one committed 828 KB artifact (420 nodes / 4,140 edges / 11 roles), and regenerating it is explicit against a local restore, withpipeline/psql.shrefusing anyDATABASE_URLthat is notmetahunt_lab. Three views — skill neighbourhood (NPMI / lift / P(B|A) / count with role as a control column), roles on their own denominator, and a Louvain + ForceAtlas2 map where choosing a cluster filters rather than recolours, because a dense graph drawn whole is a disc no matter how good the layout. The taxonomy plan merges only the three pairs that genuinely name one concept twice, and is not applied — that is a production mutation. The 87 edges / 119 skills the graph flagged as suspicious are mostly legitimate subsumption (SQLAlchemy→Python at P=1.00) that a flat taxonomy cannot express; merging them would destroy information. → ADR-0014Co-occurrence cannot tell a substitute from a complement, so the strongest 150 edges were labelled by hand
(maxikfabin/met-129-substitute-labels, PR #173). "TensorFlow or PyTorch" and "I2C and SPI" produce identical co-occurrence tables — the distinguishing word is discarded at extraction, so no NPMI threshold recovers it. Two cheap detectors were tested and both fail: conditional-probability symmetry puts complements (I2C/SPI, 1.00) above substitutes (WireGuard/OpenVPN, 0.93) with TensorFlow/PyTorch and DHCP/DNS sitting together at 0.61/0.62 with opposite answers; andnode_tech_meta's 8×18 vocabulary lands DHCP/DNS and WireGuard/OpenVPN in the same cell. Same-slot survives as a candidate generator that makes hand-labelling tractable, not as a classifier. Result: 78 COMPLEMENT, 33 IMPLIES (directional — a by-product that feeds MET-27), 32 SUBSTITUTE, 7 CONTESTED. 26% of the graph's strongest edges would be read as "learn both" when they mean "learn either."A command now names the database before it writes to it
(maxikfabin/met-133-worktree-env-..., MET-133, PR #172). A worktree.envsent a fulltaxonomy:migrate --applyinto the local lab restore. It reported complete success — 3 operations, conservation checks passed, matviews refreshed — and nothing in the output named a database; the same mistake inverted would have written to prod. Two fixes: the lab pipeline reads its ownLAB_DATABASE_URLand no longer looks at the repo.envat all, removing the shared coupling; and a new pre-Nestdb-target.tsprintstarget: host:port/databaseand refuses--applyagainst a non-local host without--yes-prod(host is the signal — names get copied between environments, hostnames do not). Known and documented limit: this stops "accidentally prod", not "accidentally the wrong local database" — but the header now makes the second case visible.
7 серпня 2026
One Telegram message per vacancy, and a card written in sentences
(fix/tg-digest-one-per-message+fix/tg-digest-skills-experience-copy, PRs #163 #164). The digest packed up to 8 vacancies into a single message under a character budget; it now sends one message per vacancy, per-chat throttled, with only the first notifying and follow-ups silent. The card was redesigned over several rounds of live testing: a fused seniority+role title that is itself the link, salary/company/domain on one line, and every condition (skills, English, experience, format, location, reservation, test assignment) as its own plain-language sentence under "Деталі:". Live testing through/previewsurfaced a real bug — the digest label showed the technical filter breakdown ("Software Engineer, Data Engineer +3 · middle/senior") instead of the subscriber's own alert name./previewand the scheduled send had also diverged and now share onepaginateDigestpath. New admin-onlyPOST /digest/debug-sendsamples real vacancies through the live render path without touching subscriptions orsent_notifications, which is what made repeatable format iteration possible. Dropped on purpose: the publish date, the quoted source excerpt (descriptions arrive as unsanitized HTML — not safe to echo yet), and the per-message subscription-name footer. → tracker
4 серпня 2026
The v2 analytics cutover had leaked; outbound clicks and digests now actually arrive
(fix/analytics-v2-call-sites, PR #161).AnalyticsServicehad no way to resolveusers.idfrom a subscription, so bothapplyClickedbranches returned after the ledger write and never reachedreal-metahunt— and the ledger dispatcher feeds the legacy sink, which is dormant in prod. NewsubscriberForSubscription/subscriberForJourneyresolve the identity, anddigest_sentfires after the delivery transaction commits, since withflushAt: 1an in-transaction capture would report a digest that a rollback undid. Three guarantees held deliberately: a subscription with nouser_idemits nothing, a journey resolving to zero or 2+ subscriptions resolves to nobody rather than a guess (no stand-in ids anywhere), and a failed identity lookup logs and drops the capture because the redirect is already sent and must never be affected.account_created/signed_inturned out to be wired already — the tracker's state table was stale on that point. → trackerSix dead legacy-journey branches deleted; autocapture enabled
(chore/analytics-dead-code, PR #162).ProductAnalyticsService.isEnabled()returned a hardcodedtrue, so every!isEnabled()branch insubscriptions.service.tshad been unreachable since it landed — the journey insert, thepersonId/journeyIdcolumns and thesubscription_createdledger enqueue increate()were all no-ops, as waslinkChat()'s person stitching. Zero runtime change; six branches simply stop looking live. Autocapture matters because an anonymous outbound click carries nousers.id, so the server correctly emits nothing — autocapture gives it an anonymousdistinct_idthatidentify(users.id)merges on login, and that is the only path reaching the top of the funnel behind the ~0.2% click-through problem.
3 серпня 2026
Homepage SEO leads with the brand and the search intent
(fix/seo-foundation, PR #160).[metahunt]andпошук роботи в ITmove to the front of the title, the description is rewritten around job-search-OS positioning, and the visible hero is aligned with it. The previous title led with a generic description, weakening branded discovery and missing the core intent.
2 серпня 2026
Analytics restarts PostHog-first
(feat/met-114-analytics-reset, MET-114, PR #156). Replaces the ledger-first implementation with a behavioural event taxonomy, founder workspace automation (pnpm posthog:founder) and a CRM-style people view; migration0037addssubscriptions.person_id. Merged with T0 (canonical person identity) and T3 (compact CRM dashboard) explicitly open — a Telegram-only subscriber without a web login can still hold multipleperson_idrows — because MET-118 was blocked onperson_idexisting in main and the migrations are additive. The remaining identity-dedup gap is MET-115. → trackerThe analytics console is replaced by one live PostHog + Postgres page
(feat/met-118-analytics-page, MET-118, PR #159). Read-only PostHog query client with a graceful unavailable state, plus metrics, funnel, sources, people roster, filters, sorting and pagination, covered by real-Postgres roster integration tests. → trackerFit % becomes visible, on a lab route rather than on
/(feat/MET-120-feed-fit-score, MET-120, PR #157). The match score was already computed on every rank pass and simply never rendered. Scoring is extracted into03-discovery/score/behind aScoreBreakdown/ScoreSignalcontract so a future signal is one array entry and zero UI changes, andon_stackmoves from anORDER BYdemote into an explicitincludeOffStackfilter so page order matches the number on screen. The review pass is the story here: a separate-lanecode-reviewerreturned REQUEST CHANGES with 9 findings, two of them real regressions the change had introduced beyond its own route — the home feed/was silently losing ~47% of matches to the new off-stack default, and a whole vacancy group could vanish from dedup if its highest-scoring duplicate happened to be off-stack. Both are fixed and pinned with tests that fail without the fix; existing consumers are pinned to their prior behaviour while/feedkeeps the new default as its deliberate point.EXPLAIN ANALYZEon the real 9,269-vacancy corpus: score and date sorts both ~79–89 ms, sorting is free, ~95% of the query is the scoring aggregate itself. → tracker
30 липня 2026
Account workspace and editable CV subscriptions
(feat/account-workspace, MET-112). Existing subscriptions receive deterministic names; new subscriptions get the same stable naming rule and can be renamed./meis now a responsive cabinet with subscription, CV, and account sections on the shared site background. The admin-only beta editor reuses the production vacancy-filter components to update one CV subscription's roles, excluded skills, and vacancy filters without mutating its CV or sibling subscriptions. Public slugs are validated and normalized at the API boundary; invalid or stale refs fail without replacing the stored snapshot, and unresolved node UUIDs never reach the browser. Feed subscriptions keep their existing matching behavior and remain rename/pause/delete only. → trackerCV-match criteria and auth foundation merged
(PR #154)./matchpreview and Telegram delivery now share real subscription-scoped criteria instead of candidate-level intent or mock role/exclusion data. Google and Telegram remain separate linkable identities on one account; Telegram widget login is removed, linking conflicts never silently merge owners, and the legacy Google/Telegram data migration is race-safe. → match tracker, auth runbook
27 липня 2026
The account page is built from the same kit as everything else, and says what connecting actually does
(feat/MET-51-user-dashboard, MET-51)./mehad grown five sections that each re-declared their own heading, container, empty state and loading line, and every row laid its label and two-to-three buttons out in a single flex line — which overflowed the moment the viewport was a phone. Now every section is aPanel, every nothing-here is anEmptyState, and each row stacks belowsm:. Two columns fromlg:with the CV panel spanning both, since its skill manager needs the width. NewAccountHeadercarries the handle, the connected providers and the admin flag, so the page opens by telling you what you are looking at. The connect copy now states the part that is not guessable: both methods sign into one account, connecting on this page is what joins them, and signing in with a method not listed there creates a separate account that cannot be merged afterwards. Three new events —identity_linked,identity_unlinked,identity_link_conflict— make "one person, two accounts" a number instead of a hunch; MET-82 (a merge flow) is gated on it.Google sign-in, alongside Telegram rather than instead of it
(feat/MET-45-google-auth, MET-45). Evidence: a Reddit-launch user refused Telegram-only auth outright, and the DOU audience has the same posture — a sign-in that demands a messenger is a door many people will not walk through.POST /auth/googleverifies a Google Identity Services ID token (google-auth-library: RS256 against Google's keys, issuer, expiry, andaud= our client id — the check that stops a token minted for another site being replayed here) and mints the same session JWT Telegram does. No client secret, no redirect URIs, no OAuth review: the scopes areopenid/email/profile, which are non-sensitive.auth_identitieswas built for this — Google is a second row against oneusersrow, andJwtAuthGuard,RolesGuard, the SSR cookie bridge and CV/subscription ownership are all untouched.AuthService.upsertUsergeneralised intoupsertIdentity, with roles now optional:ADMIN_TELEGRAM_IDSis keyed on a Telegram id, so a Google sign-in reads roles and must never rewrite them or it would demote an admin. Email adoption: a verified Google email that matches an existing row adopts it instead of creating a duplicate — safe because only waitlist rows carry an email, so nothing is owned there yet; unverified emails neither adopt nor get stored. Linking (POST /auth/link/{google,telegram},DELETE /auth/link/:provider) attaches providers to the caller's account: an identity owned by someone else is a 409, not a silent reassignment, because account merging is irreversible and needs a real flow; unlinking your last method is a 400; linking Telegram claims that chat's orphan subscriptions, which is what starts digests for a Google-first signup./auth/menow returnsemail+identities[], and/megrows a connections panel. All four login entry points render one new<AuthChoice>, so the next provider is a change in one file. Also removed: the staleclerk-auth-dashboardtracker and the Clerk env vars — Clerk was never installed. → runbookTelegram login moves off the widget onto a bot deep link
(feat/MET-5-telegram-deeplink-login, MET-5). Diagnosis of the 11-failed-logins replay (2026-07-24): the Login Widget's common path is its bad one — a visitor not already signed in to web.telegram.org gets a phone-number prompt, a code sent into the Telegram app, and a confirm tap inside a chat with Telegram, with no affordance explaining any of it. The new path never touches Telegram's JS:POST /auth/telegram/startmints a nonce + poll secret + 4-char code, the browser openst.me/<bot>?start=login_<nonce>(on mobile: the native app, where the user is already signed in), the bot asks for confirmation, and the browser pollsPOST /auth/telegram/pollfor the same 30-day session JWT it always got. New tabletelegram_login_requests(migration0032), single-use, 5-minute TTL, hourly GC. Three security properties, all load-bearing: the poll secret never enters the link, so forwarding the URL buys nothing; pressing START authorizes nothing — without the explicit confirm this is textbook device-code phishing (post the link publicly, take over whoever taps it); and confirmation is refused outside a private chat, sincecallback_datais client-supplied and a forged confirm from a group would key an account on the group id. The residual consent-phishing risk is documented in the runbook.AuthService.resolveTelegramUsernow takes an optional executor so the confirm transaction runs on one pooled connection instead of nesting a second. The widget survives as a "use the widget" fallback inside the login popover for one release —POST /auth/telegramis untouched, andtelegram_login_*events gained amethodproperty (deeplink|widget) so the two paths compare on one funnel. Removing the widget is a separate commit once the new path has real traffic. → runbook
26 липня 2026
og-image fetches get the sitemap's deadline
(feat/roster-states). Bothapp/opengraph-image.tsxandapp/vacancy/[slug]/opengraph-image.tsxfetched the API with no timeout — the exact #118 failure mode on different routes: a slow-but-connecting backend hangs past Next's 60s export cap and fails the whole build (reproduced locally today; the root og-image is exported at build time). Both now passAbortSignal.timeout(5000)and degrade to the generic card;vacanciesApi.byIdacceptsRequestInitlike the #131 fetchers.Roster rows carry a lifecycle status; block detection stops reading as user activity
(feat/roster-states).bot_blockedmoved fromUSER_ACTION_EVENTStoSYSTEM_EMITTED_EVENTS— its timestamp is when WE detected the block (my_chat_member or the third bounced send), so it showed a long-gone subscriber as "last action 4h ago" the moment the safety net fired.SubscriberActivitygains astatusDTO field (active | dormant | churned | blocked) computed server-side with the same rules as the subscriberStates tiles (dormant = active sub + ≥3 digests landed in 14d + zero user actions; blocked = deactivated with reasonblocked/unreachable), rendered as aSubscriberStatusBadge(blocked = red, asleep = amber, off = muted; active renders nothing so exceptions pop). The console's Users widget got client-side column sorting (joined / last action / digest / feed clicks) — headers toggle direction, default stays last-action-desc. Int spec now seeds a blocked chat and asserts per-row statuses + the churned tile counting it.Campaign attribution actually reaches the ledger; the funnel becomes a real ordered chain
(feat/campaign-attribution). Root cause of the "Channels shows only direct" bug: utm lived only in the current URL — a visitor landing on/radar?utm_source=redditlost the tags on the first internal<Link>click, and the/radarpicker (where campaign links point) fired nolanding_viewat all. Now the first tagged arrival is persisted client-side (localStorage, first-touch — never overwritten) and merged intolanding_view/landing_cta_clickedwhenever the URL carries no tags; the picker fireslanding_view(radar-pickervariant). History not backfilled — pre-fix channel questions live in PostHog$initial_utm_source. On top of that: Channels gains the campaign axis (one(source, campaign)row per post — first-touchutm_campaign), the funnel switches from independent per-step counts to an ordered chain anchored atlanding_view(a journey counts at step N only with every earlier step; rows are monotonic, "% of landing" is a real conversion; feed/warm subscribers show as an "entered mid-funnel" footnote via newfunnelBypass), and vanity 302s/yt/tt/tg/ig→/radarwith per-channel utm for links that get spoken, not clicked. Web analytics utilities consolidated intolib/analytics/(journey, attribution + first-touch, use-analytics, posthog-provider, vercel-analytics) — one module, one concern.
25 липня 2026
Analytics dashboard tells acquisition apart from retention
(feat/subscriber-states). The funnel shrinks to the 4-step anonymous acquisition chain (landing_view → landing_cta_clicked → subscription_created → telegram_linked) with every row shown as % of landing — row-over-row ratios on independently-counted steps produced nonsense like 125% and are gone. The headline churn tile stops countingunsubscribedevents (one /stop with N subscriptions counted N times, narrowing tracks counted as churn) and becomes a per-chat lifecycle state: active / dormant / churned — dormant = an active subscription that received ≥3 digests in 14 days with zero user actions back (the recoverable ones), churned = every linked subscription deactivated. New Delivery tab isolates system events from user behavior: digests sent, messages per chat per day (the churn-risk number, flagged past 2/day), failures by kind, unsubscribes, and a fixed 7-day daily table. Handoff/activation/digest steps keep firing as events — they just stop being funnel rows. Int spec extended with a state-classification + delivery-health case.Bot blocks become a churn signal; dead chats stop burning sends
(feat/bot-blocked-churn). Amy_chat_member→kickedupdate now deactivates the chat's subscriptions with reasonblockedand emits a newbot_blockedproduct event (method: chat_member); an unblock (member) restores exactly the block/unreachable-deactivated set and emitssubscription_reactivated(method: unblock) — explicit unsubscribes stay off. Safety net for updates the poller missed: three consecutivechat_unreachabledigest bounces deactivate the subscription (method: delivery_failure) instead of retrying it hourly forever; any successful send resets the counter. Migration0031addsdeactivated_reason+unreachable_countto subscriptions; explicit /stop, inline-button and account unsubscribes now stamp reasonuser. No dashboard changes here — the state-based churn tile lands withfeat/subscriber-states.Event hygiene: drop duplicate/misnamed client events, disable autocapture,
landing_viewgets apath(chore/event-cleanup, stacked onfix/funnel-hardening). Four cleanups from the analytics-simplification plan's Decision 4: (1) the legacycv_uploadPostHog event — a straight duplicate fired alongsidecv_upload_completedon every upload — is gone,cv_upload_completedis now the only signal. (2)subscribe_clickedis gone; the client-sideuseAnalytics().subscriptionCreated()helper only ever fired that one PostHog event under a name that promised something it didn't do, and duplicated the server-sidesubscription_created(fired once the subscription row actually exists) — deleted the helper and its three call sites (SubscribeCta,WarmSubscribe,SubscribeButton). Any PostHog insight built onsubscribe_clickedflatlines from this deploy;subscription_createdis the metric now. (3)logged_instops sending bothlogin_methodandmethodwith the identical value —login_methodonly. (4)posthog-jsautocapture is off ($pageview/session recording untouched) — the DOM-click firehose added noise, not signal, on top of the domain-specific events already captured. Also added thepath(window.location.pathname) property tolanding_view, extending the server-side browser-event allow-list to match — utm/creative_id attribution was already relayed,pathwas the one gap flagged in the 07-24 review.Funnel hardening: apply-click gating, login diagnostics,
signupevent, sitemap deadline(fix/funnel-hardening). Four measurement/robustness fixes ahead of the traffic push: (1)/go/:idnow also skips recording whenSec-Fetch-Modeis present and isn'tnavigate— prefetchers and link-preview bots thatisbotcan't name were still ~69% of new PostHog persons after the UA filter; the redirect itself is untouched and an absent header (older browsers, Telegram in-app taps) still counts as human. New unit spec covers all gate branches. (2)telegram_login_cancellednow carriesms_since_start— a user cancelled 11× in 3 minutes on 07-24 with sub-second gaps, which a human can't do; the timing separates "popup never opened" (blocker/widget failure) from a real dismissal. Session replay + console-log capture were also enabled in PostHog (prod domains only), so the next failure is watchable. (3) First-ever Telegram login now emits a client-sidesignupevent:POST /auth/telegramreturnsisNewUserfrom the user upsert, and the button firessignupon the identified journey person — the funnel previously had no signup moment at all (PostHog-only event; the ledger'stelegram_linked/subscription_createdare unchanged). (4) Every sitemap source fetch got a 5sAbortSignal.timeoutdeadline — the PR #118 incident showed a slow-but-connecting backend hangs past Next's 60s export cap and fails the whole Vercel build in a way.catchcan't rescue;tracksApi.get/facetsApi.rolesaccept an optionalRequestInitfor it.
24 липня 2026
Console home rebuilt around users and one period
(feat/console-users-widget)./dashboardnow leads with a Users widget (subscriber, joined, last action, digest/feed clicks) — the operator's first question is "who is here". Product comes second (five period-scoped tiles + activation funnel + a new first-touch Channels table), the ETL collapses into onePipelineStripline, and its former home panels moved back to their own screens. Every number on the page now answers the 24h/7d/all switch: state metrics were rewritten as flow (ProductPeriodFlowcounted fromproduct_eventsin the window), and all-time subscription state stays only on Analytics → Identity. NewUSER_ACTION_EVENTS/SYSTEM_EMITTED_EVENTSsplit (spec-enforced partition) makeslastActionAtmean "the subscriber did something", not "we sent them a digest"; the roster keeps a subscriber if they joined or acted in the window. Additive backend only, no migration; three new integration cases cover last-action semantics, the roster filter, flow counts, and first-touch attribution. → trackerOperator console rework
(feat/operator-console). Every protected screen moved under one/dashboard/*subtree (old top-level paths —/product-analytics,/sources,/taxonomy,/vacancies,/unique-vacancies,/dashboard/extraction,/dashboard/ingests/:id— are permanent redirects); one layout owns the guard, the sidebar and<main>, and each screen is one concern rendered from a shared kit (ui/layout/{PageHeader,PageBody,Panel},ui/data/{StatCard,StatGrid,StatRows,MeterRow,DataTable},ui/feedback/EmptyState). Long screens split into URL-backed tabs (?tab=) instead of a long scroll; period/population/search do real navigations./dashboardis now a widget grid where every tile and panel drills into the screen that explains it, and the 578-line react-query analytics monolith is a server component plus four panels. New/dashboard/runs(with a failed tab) replaces the on-dashboard activity stream and failure drawer. Added real 404s (public + in-console). Frontend only; no backend or product changes. → tracker/matchonboarding landing(feat/match-onboarding-page). New ad-landable stepper page: radar-style hero with live supply counts, then CV → Скіли → Ролі → Винятки, every step skippable. CV upload + skill review (remove / search-add / NPMI suggestions) run on the real/cvAPI; the manual no-CV path collects skill slugs locally and exits into the cold feed's?skills=filter, with a real skills-scoped Telegram subscription as the secondary CTA. Roles and excludes steps are functional-looking local mocks (app/match/_components/_mocks.ts) pending the role-suggestions/excludes backend PRs (design:.scratch/cv-match-flow-design.md). Promotions per the second-consumer rule:CvSkillManager→features/cv-match/(copy/className slots),RadarSubscribe→features/subscribe/SubscribeCta.Role suggestions + role hard filter on the warm feed
(feat/role-suggestions, design:.scratch/cv-match-flow-design.mdPR1).GET /cv/:id/role-suggestions(+ public sample twin) scores every VERIFIED role by the smoothed share of its last-30d vacancies the candidate covers at GOOD+ — same IDF-weighted coverage CTE the matcher uses, now extracted and shared. Top-5 returned with honestgoodCount/totalCountnumerators, declared CV role pinned first, mean-coverage fallback flaggedreducedon cold start; smoothing/floor math is a spec-tested pure function (role-suggestions.derive.ts).MatchFilters.roleNodeIdsis a hard role filter (explicit user choice ≠ soft on_stack demote), plumbed asroleIdsslugs through/cv/:id/matches,POST /ranking/match, and the warm-lens sidebar (role multiselect, suggestions lead with N/M labels, top-3 preselected once per candidate,?roles=URL-synced). rankByRefs additionally emits a personlessmatch_scoredPostHog event (coverage histogram + tier counts, page-1 sampled) — the §8 threshold-calibration raw data.Bot clicks excluded from apply analytics
(fix/bot-click-analytics). The/go/:idapply redirect now skipsapply_clicked/digest_link_clickedrecording (product_events + PostHog) when the User-Agent is a known crawler or missing (isbot); the redirect itself is unaffected. Crawlers were ~95% of recorded clicks (2019 fake clicks/30d), each minting a fresh anonymous PostHog person. Dashboard queries unchanged — the pollution stops at the source.Integration-test hotfix for the role-suggestions merge
(PR #114,fix/ranking-int-analytics-arg). The role-suggestions change added a requiredAnalyticsServicethird constructor arg toRankingServicebut didn't update the two integration specs that construct it directly (ranking.int.spec.ts,candidate-loader.int.spec.ts) — no textual conflict, so the gap only surfaced as a failingintegration (etl)job once everything landed onmain(TS2554). New sharedtest/int/analytics.ts→noopAnalytics(db)builds a realAnalyticsServicewith a no-op sink (matchScoredonly callsposthog.capture, unasserted here) and is threaded through both call sites.
22 липня 2026
First-user funnel deployed
(PR #93,f71cff8). Vercel now serves/radar/backend,/privacy, robots, and sitemap; Railway deployment0e3e25ef-f8ef-411b-8edc-f098e2b61814serves the API after migration0028. Production smoke checks passed dependency health, public-sample/private-upload isolation, CORS, and anonymous Telegram handoff with zero observed 5xx. The first API candidate failed its health gate before traffic becauseAuthServicewas not exported to guard-consuming modules;d5c5b2aadded the export and a consumer-boundary regression test before the successful rollout. Real Telegram E2E and traffic remain gated. → funnel runbookSelf-service account deletion
(feat/real-user-funnel). Authenticated users can permanently remove their Telegram identity, owned and same-chat alerts, notification ledger, CV links, and final-owner derived candidate data from/me. Migration0028makes subscription and notification cascades explicit; protected requests reload account existence/current roles, Telegram login is limited to ten attempts per IP per minute, and new login analytics no longer identifies the account UUID. Historical pseudonymous provider events remain a separate owner-handled deletion request. → runbookScheduled-delivery observability
(feat/real-user-funnel).digest_evaluateddistinguishes first/returning and matches/empty runs;digest_sentcarries the same first-digest/profile dimensions;digest_delivery_failedrecords a bounded permanent/transient class without provider error text. Evaluation and failure IDs deduplicate Temporal retries. → funnel runbookOperator page SSR auth fixed
(fix/operator-cookie-session).(investigation)pages (dashboard, product-analytics, etc.) 401'd on load because their server-rendered data fetches only ever carried the localStorage session token, which never reaches the server. Login/logout now also sync an httpOnly cookie via a newPOST/DELETE /api/sessionroute handler, andlib/api/client.tsforwards it as the Bearer header for server-side reads; the client-side localStorage flow is unchanged.(investigation)/layout.tsxredirects home when no session cookie exists, and a newerror.tsxcatches a stale/non-admin one instead of an unhandled SSR crash. → auth runbookFirst-party activation ledger and operator dashboard
(feat/analytics-ledger-dashboard, PR #95). Pseudonymous journey IDs now connect critical browser, subscription API, Telegram activation, preview, digest, click, and unsubscribe events in PostgreSQL; critical product mutations atomically enqueue evidence, a retrying dispatcher materializesproduct_events, and PostHog is a secondary sink under the same identity. Migration0029safely classifies existing subscriptions as legacy and links known Telegram accounts through concrete subscriptions without fabricating historical events or permanently owning a shared browser journey. The admin-only/product-analyticspage exposes an ordered seven-day radar funnel, isolated production/test populations, subscription delivery state, identity-integrity gaps, outbox backlog, and recent journeys without Telegram or profile data. Dispatcher/PostHog failures stay outside the main subscription and delivery failure boundary. → trackerSession summary — 14 PRs (#95–#108), acquisition + measurement + launch hardening.
Grouped by theme: Analytics ledger and funnel dashboard iterated (#95, #100, #102, #104). Building on the ledger's initial ship (detailed above),/product-analyticsgained a per-subscriber activity table (#100), an accessible funnel/subscribers/identity/journeys tab layout with@usernamelinks and funnel/feed-vs-CV charts (#102), and an ordered-funnel fix: per-step counting replaced alanding_view-anchored recursive chain that had silently dropped a journey from every later step once any earlier step was missing (#104). → tracker Radar acquisition landings polished (#96, #98)./radar/backendswapped its bare supply count for the 3 most-recent Backend vacancies as concrete proof (#96); the homepage's CV privacy disclosure moved out of the sticky mobile control bar and the sample-profile picker moved into the hero, above the fold (#98). Digest per-chat dedup and retry-hardening (#97, #105). A Telegram chat with two overlapping subscriptions no longer receives the same vacancy twice — the anti-duplicate lookup is now chat-scoped, not subscription-scoped (#97). The Temporal send retry widened from 3 attempts/~4s to 5 attempts/~1min, and the Telegram sender now retries transient network errors (ETIMEDOUT,ECONNRESET, etc.) independently of the existing 429 handling; a blocked-bot 403 still never retries (#105). Subscriber Telegram identity captured (#99).subscriptionsgained nullabletg_username/tg_first_name(migration0030), captured on/startand backfilled for existing chats viapnpm db:backfill:tg-usernames; digest-send logic untouched. Feed clicks attributed to a browser journey (#103, #107). The feed's/go/:idapply link now carries the browser's journey id; an unattributed tap records a durableapply_clickedproduct event, kept separate from Telegram digest-click attribution (#103), and surfaces in the dashboard as a per-subscriberfeedClickscount plus a standalonefeedEngagementKPI (#107). Operator cookie-auth (#101). Detailed above. Public vacancy page (#106, #108)./vacancy/[id]is now a shareable, indexable page per vacancy with OG/Twitter metadata and a dedup-count hero stat, backed by a newGET /feed/vacancy/:idendpoint returning the full description (#106); that description is now sanitized server-side with an allowlist before rendering, fixing raw HTML tags showing up as literal text (#108).
21 липня 2026
Ingest launch hardening
(feat/ingest-pipeline-refactor). RSS exact-match suppression is source-scoped, production fetch failures follow Temporal retry semantics without fixture fallback, workflow and worker fan-out is bounded, and listing updates are latest-record-wins with race-safe embedding and duplicate-cluster invalidation. No schema migration was required. → migration trackerAnonymous CV demos repaired
(feat/real-user-funnel). Seeded sample profiles now use a public sample-only match endpoint; uploaded candidates remain JWT- and owner-protected. The home feed routes sample requests through that endpoint on both server seed and client refetch. → migration trackerFirst measurable acquisition path
(feat/real-user-funnel)./radar/backendturns the existing cold Telegram subscription into a campaign landing with truthful DOU + Djinni proof, explicit intent/create/handoff events, and bounded UTM properties. Public CV/analytics disclosure, honest root metadata,robots.txt, andsitemap.xmlclose the immediate trust/discovery gaps. No deployment or ad spend was triggered.- Immediate Telegram activation value (feat/real-user-funnel). A fresh deep-link activation now reuses the read-only 14-day matcher to render up to three attributed vacancies, or an explicit zero state, after confirmation. Preview failures are isolated from the successful link, andactivation_value_shownmeasures the step without Telegram identity or filter values. → migration tracker
20 липня 2026
CORS allowlist
(fix/cors-allowlist). The API accepts browser cross-origin requests only fromWEB_BASE_URL; the configured URL is normalized to an origin before middleware setup.
20 липня 2026
Extraction outcomes made truthful
(fix/extraction-outcome-boundary). RSS records now exposepending,failed, orsucceeded; failed attempts remain eligible for RSS retry and cannot enter the Silver loader. Monitoring exposes the explicit status filter and per-ingest counts, while the operator dashboard distinguishes failed extraction from pending work. No migration or data backfill.RSS fetch finalization repaired
(fix/rss-fetch-finalization). The ingest workflow now marks an existing ingest as failed byworkflow_run_idafter exhausted fetch/storage retries, rather than leaving it inrunning. No migration or production backfill. - Analytics privacy boundary tightened (fix/analytics-privacy-contract). PostHog no longer receives Telegram chat IDs or full subscription filters; server funnel events use opaque subscription/account IDs, anddigest_sentcarries a deterministic delivery ID. Cross-subscription chat identity remains intentionally unsupported until a consented account identity contract exists.
5 липня 2026
Feed ⊕ reverse-ATS merged and flipped to the home page
(feat/merged-cold-lens, PR #64 — committed, not yet merged/deployed). The former/mergedbeta is now the feed at/: one page, two lenses — cold (browse by tracks + filters) and warm (?cv→ CV-ranked list + skill recs). The standalone classic-feed and/reverse-atsroutes folded in (/merged,/merged/:slug,/reverse-ats→ 307 →/); the reverse-ATS widgets were promoted tofeatures/cv-match/and the route deleted. Full English public UI (the Clerk-gated(investigation)dashboard stays Ukrainian; theмetahuntwordmark is intentional). a11y:LensTabsis a WAI-ARIA tablist (roving tabindex + arrow keys) wired to arole=tabpanel, keyboard-focusable fit/off-stack tooltips, focus-visible rings on the sharedButton/IconButton, and app-wideprefers-reduced-motion(durations zeroed + smooth-scroll gated). Security: CV upload is validated by content (%PDF-magic + NUL-byte reject) not the client MIME; the?cvcapability UUID is redacted from PostHog and UUID-validated client-side (malformed → cold, no 500). Legacy "merged" code vocabulary renamed to feed (FeedLensShell,use-feed-,feed:upload-cv). Deploy: web (Vercel) is self-contained; the warm-lens sample profiles + track browse needdb:seed:candidates+db:seed:tracksin prod (anddb:seed:node-slugsif not already run). No new DB migration. → tracker
3 липня 2026
Slugs in the URL
(feat/reverse-ats-candidates, PR #60 — filters epic T7). Filter URLs read?roles=backend-engineerinstead of node UUIDs. Newnodes.slug(minted once, immutable on rename, unique per(type, slug); sharedslugify/uniqueSluginlibs/database) — ingest mints a non-colliding slug before insert; a one-time backfill seed (db:seed:node-slugs) fills existing rows (C/C++/C# → c/c-2/c-3). Facets, track preset, and contextual skills emit the slug asid; a singleNodeSlugResolvermaps slugs → ids at the feed/ranking/cv/subscription-create boundary, so all downstream SQL, the stored subscription rows, and the digest replay stay id-based (no jsonb migration; old UUID subs keep matching). Frontend is id-agnostic → no functional change. Prod: run migrate +db:seed:node-slugson deploy. → tracker T7 outcome
1 липня 2026
One filter store + shared filter DTO
(refactor/filters-components). The feed and reverse-ATS now share one URL-backed filter layer instead of two divergent stores. Backend: a single class-validatorFilterParamsDto(+FeedQueryDto/MatchDto) validates bothGET /feed(query) andPOST /ranking/match(body) — the feed dropped its 18 positional@Queryargs, ranking dropped itsunknown-typed body. The feed gained multi-select seniority/format + english/employment/postedWithinDayscold filters (inArray). Frontend: one supersetFilterState+FiltersApi(URL-backed viauseUrlFilters, swappable to a state backend); reverse-ATS moved off localuseStateonto it (filters now bookmarkable, re-rank via a URL-filters effect) andfilter-model.tswas deleted; one shared<FilterRail lens>serves both pages. URL keys went plural (?seniorities=…), breaking old singular bookmarks (pre-launch, acceptable). → tracker
25 червня 2026
CV skill-recommendations widget shipped
(feat/cv-skill-recommendations, PR #55). "Що вчити далі" — marginal-counterfactual unlock list over the role cohort. → ADR-0009, trackerRecommendation skill-metadata gates shipped
(feat/recs-skill-metadata, PR #56). Newnode_tech_metatable (LLM-classified category/stack/is_core/generic) +node_skill_coocmatview (NPMI, refreshed withnode_stats); BAMLClassifySkills+classify-skillsbackfill CLI. Gates drop foreign-stack (F2) + already-known language (F1, TS⇒JS) + substitute frameworks (cooc npmi≥0.30) from "learn next"; redundant footer limited to generic skills. Prod backfilled 1228 rows. IDF/node_statsuntouched. → ADR-0010, trackerreverse-ATS stack-fit soft-demote
(feat/reverse-ats-v2-role-fit).rankByRefssortson_stack DESCfirst — off-stack vacancies (required core tech outside the candidate's stack-set) sink below in-stack ones (soft, not a filter); webMatchCardshows an «інший стек» badge. Fixes cross-stack match leak (e.g. QA/mobile in a backend CV). → reverse-ats §rev 2026-06-25
17 червня 2026
Tech-vacancy filter shipped
(feat/tech-filter, PR #48). Two hard-skip gates: Gate 1 (passesTechGate, recall-biased regex at ingest) + Gate 2 (LLMisTechat loader, precision-biased). Nois_techcolumn;rss_recordspersist so dropped rows stay re-derivable. Gate 1 blacklist scans role head (parens stripped) with Unicode-safe word boundaries;business-developadded.scripts/cleanup-nontech.tsdeleted 19 junk prod rows. → migration trackerThreshold auto-verify removed
(refactor/skill-verify).autoVerifySkillsTemporal schedule deleted; skill verification is now a deliberate operator decision. Mention count survives only as a triage signal in the NEW queue. New policy doc: taxonomy-verification-policy.md. Tracker retired to_done/.
4 червня 2026
ETL source modules regrouped from flat
src/into stage folders:01-ingest/,02-enrich/,03-discovery/,04-notify/, plusadmin/andplatform/. Pure structural move (git mv+ import fixups); no behaviour change, build green, 201 unit tests pass. Folder map + dependency rules in overview.
24 травня 2026
Taxonomy curation moved to a split-pane workspace (
feat/taxonomy-workspace). Dashboard-style/taxonomyreplaced by list-with-always-on-detail: filters + full list left, sticky detail panel right. All filter + selected-node state insearchParamsfor deep-links and history. Backend addedGET /admin/taxonomy/nodes(CTE-based, pagination, multi-status filter) andPATCH /admin/taxonomy/nodes/:id/rename(promotes old canonical to alias;409withsuggestion.mergeTargetIdon collision). LegacyGET /admin/taxonomy/queue+NodeDrawermodal deleted. → migration tracker
11 травня 2026
BAML prompt v2 + token-usage tracking + cost dashboard shipped (
feat/extraction-prompt-v2). Everyrss_records.extracted_datacarries a{ _v, _usage }sidecar. Prompt v2 adds canonical-taxonomy injection (60s cache), UA-market context, anti-fluff rules, few-shot examples. Migrations0008/0009introduceextraction_costview with per-model pricing. NewGET /extraction-cost/summary+/dashboard/extractionweb page.apps/etl/scripts/reextract-vacancies.tsfor one-shot re-runs after prompt bumps. → migration tracker, runbook
9 травня 2026
Operator dashboard P1+P2+P3 + two polish rounds shipped (
feat/operator-dashboard-p3, PR #12). Sidebar-driven(investigation)layout,/dashboard(KPI strip + sparklines + activity stream),/sources(per-source health + skill-verified % joined client-side),/taxonomy(coverage panel + queue tabs + node drawer). Three SVG primitives (Sparkline,StackedBar,Donut) incomponents/data/— no chart library./monitoring308-redirected to/dashboard.SeniorityBadge+CopyButtonpolish in round 2. → migration trackerStage 05 closed 2026-05-08; Stage 06 opens with the dashboard as entry surface. →
roadmap.md
6 травня 2026
fill-vacanciesCLI shipped (apps/etl/scripts/fill-vacancies.ts): walks bronze records intovacancieswith streaming progress, taxonomy coverage instrumentation, and gap-list output per type. Re-run is a clean no-op. Verified: 86 bronze → 86 vacancies, 35 companies, 521 nodes asstatus='NEW'. → commitca6908dTaxonomy seed reworked:
node_aliasesunique constraint moved to(name, type)(migration0006). Seed populated with 216 SKILL + 21 DOMAIN + 80 ROLE canonicals + 776 aliases, allVERIFIED. → commit51223fc, migration trackervacancy_nodesPK collision fixed: duplicate(vacancy_id, node_id)rows from alias variants now deduplicated before insert, preferringrequired=true. → commit921b4e1Taxonomy moderation API:
/admin/taxonomy/{coverage,queue,nodes/:id,nodes/:id/fuzzy-matches}. Trigram thresholds: ROLE/DOMAINminSim=0.55, SKILLminSim=0.65+word_similaritygate. Migration0007enablespg_trgm. → commitb6e1052, migration trackerFirst gap-driven
nodes.jsoniteration (Tier 1): ROLE coverage 64.1% → 97.2%, DOMAIN 53.5% → 88.0%, SKILL 53.0% → 63.4%. → commiteaf46acGET /vacanciessilver feed shipped +apps/web/app/(investigation)/vacancies/page. Typed fetcher inlib/api/vacancies.ts. → commit0340ecf, migration trackermd/engineering/FRONTEND.mdadded (Next.js 16 + Server Components,lib/api/conventions). → commit58e96f8
5 травня 2026
Silver-layer loader pipeline shipped (
feat/loader-pipeline, T1–T18). Six new tables (companies,company_identifiers,nodes,node_aliases,vacancies,vacancy_nodes) + seven enums.external_idderived per-source at parse time, lockedNOT NULL. → migration tracker, runbookNew
apps/etl/src/loader/module:CompanyResolverService,NodeResolverService(race-safe, alias-keyed),VacancyLoaderService(transactional upsert +vacancy_nodesrewrite),LoaderBackfillService,LoadVacancyActivity.vacancyPipelineWorkflowstarts one child per extracted record;WorkflowIdReusePolicy.ALLOW_DUPLICATE_FAILED_ONLYenables retry on next ingest pass.Suite: 25 suites / 99 tests; DB smoke at
apps/etl/scripts/loader-smoke.ts.
3 травня 2026
Daily RSS ingest moved to a Temporal Schedule (
rss-ingest-hourly, calendar-based,SKIPoverlap,Europe/Kyiv). NewrssIngestAllWorkflowfans out one child per source withparentClosePolicy: ABANDON.RSS_INGEST_INTERVAL_HOURSenv var controls cadence.Extraction made per-record best-effort via
Promise.allSettled; failure count recorded inrss_ingests.error_message.Activity retry policies normalized:
3attempts,5s/10s/20sbackoff;finalizeIngestkeeps 5 attempts.POST /rss/extract-missing?limit=Nadded for synchronous backfill of un-extracted records.Child workflow IDs changed to
rss-ingest-<code>-<YYYY-MM-DDTHH-MM-SSZ>.md/runbook/failure-recovery.mdadded. Suite: 13 suites / 48 tests.
3 травня 2026 (frontend import)
Frontend imported as
@metahunt/web(apps/web/) from standalonemetahunt-clientrepo (no history transfer). Vercel deploy approach switched to new project + sequential domain migration. Rootpackage.jsongained per-app scripts (dev:web,build:web, etc.). → ADR-0005, migration tracker, PR #4.Monitoring API added to ETL: six read-only endpoints under
MonitoringModule(/monitoring/{stats,sources,ingests,…}), CORSorigin: "*"for cross-origin dev access.First
apps/web→ backend integration:lib/api/monitoring.tstyped fetcher, Server-ComponentPromise.allfetch, URL-driven filter state,/monitoringpage with stat cards +RssRecordCardfeed. PR #5.
1 травня 2026
BAML extractor lands as the single source of truth for vacancy shape, prompt, and per-field rules (
@boundaryml/baml@^0.222,apps/etl/baml_src/). Schema redesigned to camelCase + nested (salary.{min,max,currency},skills.{required[],optional[]},locations[], etc.). → ADR-0004OpenAI extractor and Zod re-validation removed;
EXTRACTOR_PROVIDER ∈ {baml, placeholder}.BamlVacancyExtractoris one line:return b.ExtractVacancy(text).Real-world fixture added: DOU.ua RSS item as TS module + BAML
testblock for prompt iteration.Suite: 12 suites / 43 tests.
29 квітня 2026
RSS+Temporal port — T10–T13 (
RssSchedulerService,RssController,RssModulewired intoAppModule, workflow bundler fix).ingestAll()/ingestRemote()replace the legacyingestAll(local).GET /rssreturns202 Accepted.autoStartgated onNODE_ENV !== 'test'. Suite: 12 suites / 43 tests. → migration trackerdotenv.config()added tomain.tssopnpm start:devresolves env without--env-file-if-exists.GET /healthzadded: parallel Postgres + MinIO + Temporal checks,200 ok/503 degraded. RailwayhealthcheckPathswitched to/healthz.Temporal Cloud support:
TEMPORAL_API_KEYenables TLS + API-key auth; local dev stays plaintext.
28 квітня 2026
RSS+Temporal port — T3–T9 (StorageModule/MinIO, Temporal in compose, five activities, workflow). Docker Compose gained
minio+minio-initsidecar andtemporalio/auto-setup+ UI. Activities ported under TDD:RssFetchActivity,RssParseActivity,RssExtractActivity(introducedVacancyExtractorinterface + OpenAI impl),RssFinalizeActivity, and therssIngestWorkflow. Suite: 9 suites / 37 tests. → migration trackerExtractor abstraction:
VACANCY_EXTRACTORtoken selectsPlaceholderVacancyExtractororOpenAiVacancyExtractorviaLLM_EXTRACTION_ENABLED; future swap = new impl, no activity/workflow changes.workflowsPathlocked toresolve(__dirname, 'workflows');rss/workflows/index.tsbarrel added (required by Temporal's webpack autogen entrypoint).
26 квітня 2026
Monorepo scaffold on pnpm workspaces:
apps/etl(@metahunt/etl) +libs/database(@metahunt/database,@Global()Nest module). Old_metahunt/archived as read-only reference. → ADR-0001apps/etlswitched from headlesscreateApplicationContextto a full HTTP server;GET /returns{ greeting }as a cross-workspace DI canary. → ADR-0002Dev scripts added:
pnpm dev(paralleltsc -w+nest start --watch),start:prod,start:debug.Engineering docs (
md/) set up with Snapshot + Journal layout; package-levelREADME.mdadded for each package.@metahunt/databasemigrated from placeholder token to real Drizzle + Postgres provider (DRIZZLE) with schema, migrations, and seeds. Health endpoint verifies DB viaSELECT 1.Env behavior unified:
node --env-file-if-exists=../../.envfor local; process env primary everywhere.Migration drift artifact
0004_purple_exodusremoved; migration hygiene rule documented.Railway IaC added: root
Dockerfile(multi-stage, Node 22),railway.jsonwith pre-deploy migrations,.dockerignore. SSH remote + git identity pinned tom4xx1k. →md/runbook/railway-deploy.mdDocker build hardened through three iterations: recursive workspace install in
buildstage,tsconfig.base.jsoncopied to runtime, workspacenode_modulesforlibs/databasecopied sots-noderesolves during pre-deploy migrations.Railway
watchPatterns, healthcheck path, and runbook operational rules finalized.