Що нового
111 записів, від найновішого. Це той самий журнал, який ведеться в репозиторії — розкрий запис, якщо хочеш інженерні деталі.
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.