logo[мetahunt]
> журнал змін

Що нового

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 one max-w-[880px] flex-col stack with all 433 lines inline — the narrowest page in the app, against HubShell's 1080 and PageBody'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] at xl, two columns at lg where 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 behind hidden, because the second copy would double the crawlable text on ~4.9k pages. The similar-vacancy list was throwing away data it already had: loadSimilar returns full VacancyDtos 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 a p-6 card inside a px-6 section leaves 279px on a 375px screen. .vacancy-body is mobile-first now (15px/1.65, 16px/1.7 from sm) and the card goes full-bleed below sm, 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-child to stop being child-only, or a description closing on a blockquote gained 1em of dead space. Deliberately unchanged: force-static + revalidate = 900 and both JSON-LD blocks. The page renders identically for everyone, and a single cookie read would make it dynamic and uncacheable again.
  • docker compose watch is a foreground process, and restart: unless-stopped was 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/web is bind-mounted now, so docker compose up -d is enough and a stale web container is not a reachable state. etl keeps sync+restart because it is a stateful poller that genuinely needs a container restart per change. Both app services drop to restart: "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 .next volume landed root-owned — the sidecar ignore file drops .next from the build context, so Compose seeded the volume from nothing and next dev, running as node, 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-recreate returns the same volume ids, -V returns new ones), so after a dependency change the stale node_modules mask would have shadowed the fresh install and defeated the pnpm-lock.yaml rebuild rule — both scripts pass -V. And depends_on: - etl only waits for the container to exist, so the first page load raced etl's ~30s boot and 500'd on ECONNREFUSED; there is a healthcheck now, gating on GET / (process up + SELECT 1) rather than /healthz, which also demands Temporal and object storage and would abort the whole up on 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, tracker analytics-one-identity). Phase 4, in the order the brief demanded: rewrite, prove, delete, drop. The scope was not "delete two tables". 14 of product-analytics.service.ts's 15 methods read product_events or analytics_journeys, most through raw SQL rather than the drizzle objects — grepping for productEvents under-reports it by a wide margin. The roster (people, subscriberActivity, subscriberStates) moved to PostHog keyed on the person; delivery (deliverySummary, deliveryDaily) moved to sent_notifications, the domain record of what we actually sent; orderedFunnel, channels, retention, growth, feedEngagement, periodFlow, recentJourneys, identityHealth and updateJourney were 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 null subscription_id, and 1 send whose message failed after the vacancy rows were written. telegramLinkedAt needed no analytics store at all: subscriptions.linked_at matched all 40 events within 5 seconds and covers one subscription more. A digest is an hour, not a row. sent_notifications holds one row per vacancy, so counting rows overstates digests 2–4×; grouping on (subscription_id, date_trunc('hour', sent_at)) reproduces digest_sent exactly, because the schedule is hourly. Three fields had no source left and were named rather than quietly nulled: ctaClickedAt and firstSeenAt are deleted (producers dead since July, rendered nowhere, and joinedAt answers the second honestly), source was rewritten on PostHog's $referring_domain because it is rendered. The plan said "retarget the outbox at PostHog"; the same step drops analytics_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 already PostHogClient; the outbox retired with the table it drained into. ?j= survives the drop: subscriptions.journey_id keeps 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 value analytics_journeys.person_id held (260 of 267 rows), with PostHog's claim-time alias covering the rest. redirect.controller.ts was 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 carried surface: "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, because expect.objectContaining ignores 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 in deliverySummary, 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_failed lives 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.ts still 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, tracker analytics-one-identity). #189 gave the unattributable outbound tap an is_anonymous: true flag 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 /go traffic), so vacancy_outbound_clicked was meaningless to anyone who forgot the filter. A flag you must remember is not a safeguard. The fallback branch now emits vacancy_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_id is a NOT NULL foreign key, so a /go tap with neither ?s= nor ?j= cannot be written to Postgres at allapplyClicked() sends it to PostHog and returns. The runbook asked to compare raw surface=web_feed against apply_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 (dropping product_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.tsx calls getOrCreateJourneyId()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.ts passes an absent Sec-Fetch-Mode as 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 seo workflow has been auditing railway.com since July
    (fix/seo-audit-vercel-only, PR #194). It triggers on deployment_status, and both providers send those — so every ETL deploy handed seo-audit.ts the Railway dashboard URL and got 32 failures: no sitemap, lang="en", no h1, 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 red seo on main had 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 adding VERCEL_AUTOMATION_BYPASS_SECRET to 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 on deployment.creator.login == 'vercel[bot]', the field that actually distinguishes them (Railway sends railway-app[bot], environment metahunt / production).

17 серпня 2026

  • Two things the analytics cutover only revealed in production
    (fix/analytics-outbox-drain-lock PR #188, fix/anonymous-click-identity PR #189). First: the drain query gained a LEFT JOIN subscriptions so a ledger event lands on the subscription's person, but kept its bare FOR 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 outbox locks 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 seeded product_events directly, so drain() had no integration coverage at all; it has three tests now. Second, and the more interesting one: the outbound-click fallback captured with a randomUUID() 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 carries is_anonymous: true and 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 after SUCCESS, 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, tracker analytics-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 a users.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 was return;, an allow-list of one name ($pageview) discarded seventeen more on the last line before the SDK, and pageviews were switched off and guarded behind identify(), 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 $pageview plus autocapture. A security fix rides along: ?cv= was stripped from $current_url and $referrer but 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_linked and subscription_created finally have emitters after being queried six times and emitted zero, CreateSubscriptionRequest.journeyId is 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, and pnpm analytics:catalog now 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 no digest_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.slug is 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). Migration 0051 adds CHECK (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 authoritative extracted_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 fixed slugifyCompany had 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). CaptureMarketSnapshotActivity copied the whole market into Postgres on every hourly ingest — to_jsonb(p) over ~13k positions, TOASTing to 2.2 GB, plus all ~130k position_nodes. 68 MB per run, no retention, and no reader outside positions.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 a try/catch that logged a warning, so rss_ingests reported completed for six hours after inserts had already begun failing on a full disk — a swallowed exception turned a hard failure into a silent one. Lost: ~400 rss_records that aged out of the source feed windows before recovery. Not lost: analytics_outbox drained 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_cooc counted 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_nodes reads, and node_stats is 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. HowItWorks stays below the feed.
  • A track badge now predicts its own click
    (maxikfabin/met-141-track-required-skill-count, MET-141, PR #180). track_counts counted 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). Adding AND pn.is_required to 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-written unique_vacancies → canonical_vacancy_id → vacancy_nodes join in node_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, no SELECT , no embeddings or dedup internals), positions (one row per group: canonical facts + representative_posting_id as a display pointer + group freshness/counts), position_nodes (canonical taxonomy links, required and optional). Feed, market, facets, tracks, contextual skills, track_counts and 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, and node_skill_cooc came 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 state unit: positions, asOf and window; per-source volume says unit: source_postings and 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 a POSTING-GRAIN-EXEMPT comment and a guard test fails any new* raw aggregate that lacks one. Additive migrations only (00440046), 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-grainfix/reconcile-position-rollupsfeat/deferred-position-fksmaxikfabin/met-128-require-position-group, MET-128, PRs #165 #166 #167 #169). unique_vacancy_id had been carrying two meanings at once — position identity and dedup pipeline state — which forced product consumers to reconstruct a position with coalesce(...) 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 to deduplicated_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 stale last_seen_at, 853 also stale on first_seen_at); 1c.0 makes the creation FKs DEFERRABLE INITIALLY DEFERRED and 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 sets NOT 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. VacancyUpsertValues now 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. → tracker
  • The 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 is lab, lab:build, lab:check, lab:data and nothing is called dev/build/lint/test, so pnpm dev, pnpm lint and pnpm build:all skip 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, with pipeline/psql.sh refusing any DATABASE_URL that is not metahunt_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-0014
  • Co-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; and node_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 .env sent a full taxonomy:migrate --apply into 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 own LAB_DATABASE_URL and no longer looks at the repo .env at all, removing the shared coupling; and a new pre-Nest db-target.ts prints target: host:port/database and refuses --apply against 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 /preview surfaced 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. /preview and the scheduled send had also diverged and now share one paginateDigest path. New admin-only POST /digest/debug-send samples real vacancies through the live render path without touching subscriptions or sent_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). AnalyticsService had no way to resolve users.id from a subscription, so both applyClicked branches returned after the ledger write and never reached real-metahunt — and the ledger dispatcher feeds the legacy sink, which is dormant in prod. New subscriberForSubscription / subscriberForJourney resolve the identity, and digest_sent fires after the delivery transaction commits, since with flushAt: 1 an in-transaction capture would report a digest that a rollback undid. Three guarantees held deliberately: a subscription with no user_id emits 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_in turned out to be wired already — the tracker's state table was stale on that point. → tracker
  • Six dead legacy-journey branches deleted; autocapture enabled
    (chore/analytics-dead-code, PR #162). ProductAnalyticsService.isEnabled() returned a hardcoded true, so every !isEnabled() branch in subscriptions.service.ts had been unreachable since it landed — the journey insert, the personId/journeyId columns and the subscription_created ledger enqueue in create() were all no-ops, as was linkChat()'s person stitching. Zero runtime change; six branches simply stop looking live. Autocapture matters because an anonymous outbound click carries no users.id, so the server correctly emits nothing — autocapture gives it an anonymous distinct_id that identify(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 пошук роботи в IT move 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; migration 0037 adds subscriptions.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 multiple person_id rows — because MET-118 was blocked on person_id existing in main and the migrations are additive. The remaining identity-dedup gap is MET-115. → tracker
  • The 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. → tracker
  • Fit % 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 into 03-discovery/score/ behind a ScoreBreakdown/ScoreSignal contract so a future signal is one array entry and zero UI changes, and on_stack moves from an ORDER BY demote into an explicit includeOffStack filter so page order matches the number on screen. The review pass is the story here: a separate-lane code-reviewer returned 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 /feed keeps the new default as its deliberate point. EXPLAIN ANALYZE on 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. /me is 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. → tracker
  • CV-match criteria and auth foundation merged
    (PR #154). /match preview 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). /me had 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 a Panel, every nothing-here is an EmptyState, and each row stacks below sm:. Two columns from lg: with the CV panel spanning both, since its skill manager needs the width. New AccountHeader carries 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/google verifies a Google Identity Services ID token (google-auth-library: RS256 against Google's keys, issuer, expiry, and aud = 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 are openid/email/profile, which are non-sensitive. auth_identities was built for this — Google is a second row against one users row, and JwtAuthGuard, RolesGuard, the SSR cookie bridge and CV/subscription ownership are all untouched. AuthService.upsertUser generalised into upsertIdentity, with roles now optional: ADMIN_TELEGRAM_IDS is 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/me now returns email + identities[], and /me grows 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 stale clerk-auth-dashboard tracker and the Clerk env vars — Clerk was never installed. → runbook
  • Telegram 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/start mints a nonce + poll secret + 4-char code, the browser opens t.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 polls POST /auth/telegram/poll for the same 30-day session JWT it always got. New table telegram_login_requests (migration 0032), 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, since callback_data is 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.resolveTelegramUser now 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/telegram is untouched, and telegram_login_* events gained a method property (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). Both app/opengraph-image.tsx and app/vacancy/[slug]/opengraph-image.tsx fetched 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 pass AbortSignal.timeout(5000) and degrade to the generic card; vacanciesApi.byId accepts RequestInit like the #131 fetchers.
  • Roster rows carry a lifecycle status; block detection stops reading as user activity
    (feat/roster-states). bot_blocked moved from USER_ACTION_EVENTS to SYSTEM_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. SubscriberActivity gains a status DTO 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 reason blocked/unreachable), rendered as a SubscriberStatusBadge (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=reddit lost the tags on the first internal <Link> click, and the /radar picker (where campaign links point) fired no landing_view at all. Now the first tagged arrival is persisted client-side (localStorage, first-touch — never overwritten) and merged into landing_view/landing_cta_clicked whenever the URL carries no tags; the picker fires landing_view (radar-picker variant). 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-touch utm_campaign), the funnel switches from independent per-step counts to an ordered chain anchored at landing_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 new funnelBypass), and vanity 302s /yt /tt /tg /ig/radar with per-channel utm for links that get spoken, not clicked. Web analytics utilities consolidated into lib/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 counting unsubscribed events (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). A my_chat_memberkicked update now deactivates the chat's subscriptions with reason blocked and emits a new bot_blocked product event (method: chat_member); an unblock (member) restores exactly the block/unreachable-deactivated set and emits subscription_reactivated (method: unblock) — explicit unsubscribes stay off. Safety net for updates the poller missed: three consecutive chat_unreachable digest bounces deactivate the subscription (method: delivery_failure) instead of retrying it hourly forever; any successful send resets the counter. Migration 0031 adds deactivated_reason + unreachable_count to subscriptions; explicit /stop, inline-button and account unsubscribes now stamp reason user. No dashboard changes here — the state-based churn tile lands with feat/subscriber-states.
  • Event hygiene: drop duplicate/misnamed client events, disable autocapture, landing_view gets a path
    (chore/event-cleanup, stacked on fix/funnel-hardening). Four cleanups from the analytics-simplification plan's Decision 4: (1) the legacy cv_upload PostHog event — a straight duplicate fired alongside cv_upload_completed on every upload — is gone, cv_upload_completed is now the only signal. (2) subscribe_clicked is gone; the client-side useAnalytics().subscriptionCreated() helper only ever fired that one PostHog event under a name that promised something it didn't do, and duplicated the server-side subscription_created (fired once the subscription row actually exists) — deleted the helper and its three call sites (SubscribeCta, WarmSubscribe, SubscribeButton). Any PostHog insight built on subscribe_clicked flatlines from this deploy; subscription_created is the metric now. (3) logged_in stops sending both login_method and method with the identical value — login_method only. (4) posthog-js autocapture 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 the path (window.location.pathname) property to landing_view, extending the server-side browser-event allow-list to match — utm/creative_id attribution was already relayed, path was the one gap flagged in the 07-24 review.
  • Funnel hardening: apply-click gating, login diagnostics, signup event, sitemap deadline
    (fix/funnel-hardening). Four measurement/robustness fixes ahead of the traffic push: (1) /go/:id now also skips recording when Sec-Fetch-Mode is present and isn't navigate — prefetchers and link-preview bots that isbot can'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_cancelled now carries ms_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-side signup event: POST /auth/telegram returns isNewUser from the user upsert, and the button fires signup on the identified journey person — the funnel previously had no signup moment at all (PostHog-only event; the ledger's telegram_linked/subscription_created are unchanged). (4) Every sitemap source fetch got a 5s AbortSignal.timeout deadline — 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 .catch can't rescue; tracksApi.get/facetsApi.roles accept an optional RequestInit for it.

24 липня 2026

  • Console home rebuilt around users and one period
    (feat/console-users-widget). /dashboard now 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 one PipelineStrip line, 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 (ProductPeriodFlow counted from product_events in the window), and all-time subscription state stays only on Analytics → Identity. New USER_ACTION_EVENTS/SYSTEM_EMITTED_EVENTS split (spec-enforced partition) makes lastActionAt mean "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. → tracker
  • Operator 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. /dashboard is 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
  • /match onboarding 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 /cv API; 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: CvSkillManagerfeatures/cv-match/ (copy/className slots), RadarSubscribefeatures/subscribe/SubscribeCta.
  • Role suggestions + role hard filter on the warm feed
    (feat/role-suggestions, design: .scratch/cv-match-flow-design.md PR1). 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 honest goodCount/totalCount numerators, declared CV role pinned first, mean-coverage fallback flagged reduced on cold start; smoothing/floor math is a spec-tested pure function (role-suggestions.derive.ts). MatchFilters.roleNodeIds is a hard role filter (explicit user choice ≠ soft on_stack demote), plumbed as roleIds slugs 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 personless match_scored PostHog 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/:id apply redirect now skips apply_clicked/digest_link_clicked recording (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 required AnalyticsService third constructor arg to RankingService but 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 failing integration (etl) job once everything landed on main (TS2554). New shared test/int/analytics.tsnoopAnalytics(db) builds a real AnalyticsService with a no-op sink (matchScored only calls posthog.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 deployment 0e3e25ef-f8ef-411b-8edc-f098e2b61814 serves the API after migration 0028. 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 because AuthService was not exported to guard-consuming modules; d5c5b2a added the export and a consumer-boundary regression test before the successful rollout. Real Telegram E2E and traffic remain gated. → funnel runbook
  • Self-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. Migration 0028 makes 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. → runbook
  • Scheduled-delivery observability
    (feat/real-user-funnel). digest_evaluated distinguishes first/returning and matches/empty runs; digest_sent carries the same first-digest/profile dimensions; digest_delivery_failed records a bounded permanent/transient class without provider error text. Evaluation and failure IDs deduplicate Temporal retries. → funnel runbook
  • Operator 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 new POST/DELETE /api/session route handler, and lib/api/client.ts forwards it as the Bearer header for server-side reads; the client-side localStorage flow is unchanged. (investigation)/layout.tsx redirects home when no session cookie exists, and a new error.tsx catches a stale/non-admin one instead of an unhandled SSR crash. → auth runbook
  • First-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 materializes product_events, and PostHog is a secondary sink under the same identity. Migration 0029 safely 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-analytics page 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. → tracker
  • Session 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-analytics gained a per-subscriber activity table (#100), an accessible funnel/subscribers/identity/journeys tab layout with @username links and funnel/feed-vs-CV charts (#102), and an ordered-funnel fix: per-step counting replaced a landing_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/backend swapped 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). subscriptions gained nullable tg_username/tg_first_name (migration 0030), captured on /start and backfilled for existing chats via pnpm db:backfill:tg-usernames; digest-send logic untouched. Feed clicks attributed to a browser journey (#103, #107). The feed's /go/:id apply link now carries the browser's journey id; an unattributed tap records a durable apply_clicked product event, kept separate from Telegram digest-click attribution (#103), and surfaces in the dashboard as a per-subscriber feedClicks count plus a standalone feedEngagement KPI (#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 new GET /feed/vacancy/:id endpoint 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 tracker
  • Anonymous 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 tracker
  • First measurable acquisition path
    (feat/real-user-funnel). /radar/backend turns 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, and sitemap.xml close 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, and activation_value_shown measures 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 from WEB_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 expose pending, failed, or succeeded; 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 by workflow_run_id after exhausted fetch/storage retries, rather than leaving it in running. 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, and digest_sent carries 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 /merged beta 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-ats routes folded in (/merged, /merged/:slug, /reverse-ats → 307 → /); the reverse-ATS widgets were promoted to features/cv-match/ and the route deleted. Full English public UI (the Clerk-gated (investigation) dashboard stays Ukrainian; the мetahunt wordmark is intentional). a11y: LensTabs is a WAI-ARIA tablist (roving tabindex + arrow keys) wired to a role=tabpanel, keyboard-focusable fit/off-stack tooltips, focus-visible rings on the shared Button/IconButton, and app-wide prefers-reduced-motion (durations zeroed + smooth-scroll gated). Security: CV upload is validated by content (%PDF- magic + NUL-byte reject) not the client MIME; the ?cv capability 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 need db:seed:candidates + db:seed:tracks in prod (and db:seed:node-slugs if 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-engineer instead of node UUIDs. New nodes.slug (minted once, immutable on rename, unique per (type, slug); shared slugify/uniqueSlug in libs/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 as id; a single NodeSlugResolver maps 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-slugs on 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-validator FilterParamsDto (+ FeedQueryDto/MatchDto) validates both GET /feed (query) and POST /ranking/match (body) — the feed dropped its 18 positional @Query args, ranking dropped its unknown-typed body. The feed gained multi-select seniority/format + english/employment/postedWithinDays cold filters (inArray). Frontend: one superset FilterState + FiltersApi (URL-backed via useUrlFilters, swappable to a state backend); reverse-ATS moved off local useState onto it (filters now bookmarkable, re-rank via a URL-filters effect) and filter-model.ts was 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, tracker
  • Recommendation skill-metadata gates shipped
    (feat/recs-skill-metadata, PR #56). New node_tech_meta table (LLM-classified category/stack/is_core/generic) + node_skill_cooc matview (NPMI, refreshed with node_stats); BAML ClassifySkills + classify-skills backfill 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_stats untouched. → ADR-0010, tracker
  • reverse-ATS stack-fit soft-demote
    (feat/reverse-ats-v2-role-fit). rankByRefs sorts on_stack DESC first — off-stack vacancies (required core tech outside the candidate's stack-set) sink below in-stack ones (soft, not a filter); web MatchCard shows 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 (LLM isTech at loader, precision-biased). No is_tech column; rss_records persist so dropped rows stay re-derivable. Gate 1 blacklist scans role head (parens stripped) with Unicode-safe word boundaries; business-develop added. scripts/cleanup-nontech.ts deleted 19 junk prod rows. → migration tracker
  • Threshold auto-verify removed
    (refactor/skill-verify). autoVerifySkills Temporal 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/, plus admin/ and platform/. 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 /taxonomy replaced by list-with-always-on-detail: filters + full list left, sticky detail panel right. All filter + selected-node state in searchParams for deep-links and history. Backend added GET /admin/taxonomy/nodes (CTE-based, pagination, multi-status filter) and PATCH /admin/taxonomy/nodes/:id/rename (promotes old canonical to alias; 409 with suggestion.mergeTargetId on collision). Legacy GET /admin/taxonomy/queue + NodeDrawer modal deleted. → migration tracker

11 травня 2026

  • BAML prompt v2 + token-usage tracking + cost dashboard shipped (feat/extraction-prompt-v2). Every rss_records.extracted_data carries a { _v, _usage } sidecar. Prompt v2 adds canonical-taxonomy injection (60s cache), UA-market context, anti-fluff rules, few-shot examples. Migrations 0008/0009 introduce extraction_cost view with per-model pricing. New GET /extraction-cost/summary + /dashboard/extraction web page. apps/etl/scripts/reextract-vacancies.ts for 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) in components/data/ — no chart library. /monitoring 308-redirected to /dashboard. SeniorityBadge + CopyButton polish in round 2. → migration tracker

  • Stage 05 closed 2026-05-08; Stage 06 opens with the dashboard as entry surface. → roadmap.md

6 травня 2026

  • fill-vacancies CLI shipped (apps/etl/scripts/fill-vacancies.ts): walks bronze records into vacancies with 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 as status='NEW'. → commit ca6908d

  • Taxonomy seed reworked: node_aliases unique constraint moved to (name, type) (migration 0006). Seed populated with 216 SKILL + 21 DOMAIN + 80 ROLE canonicals + 776 aliases, all VERIFIED. → commit 51223fc, migration tracker

  • vacancy_nodes PK collision fixed: duplicate (vacancy_id, node_id) rows from alias variants now deduplicated before insert, preferring required=true. → commit 921b4e1

  • Taxonomy moderation API: /admin/taxonomy/{coverage,queue,nodes/:id,nodes/:id/fuzzy-matches}. Trigram thresholds: ROLE/DOMAIN minSim=0.55, SKILL minSim=0.65 + word_similarity gate. Migration 0007 enables pg_trgm. → commit b6e1052, migration tracker

  • First gap-driven nodes.json iteration (Tier 1): ROLE coverage 64.1% → 97.2%, DOMAIN 53.5% → 88.0%, SKILL 53.0% → 63.4%. → commit eaf46ac

  • GET /vacancies silver feed shipped + apps/web/app/(investigation)/vacancies/ page. Typed fetcher in lib/api/vacancies.ts. → commit 0340ecf, migration tracker

  • md/engineering/FRONTEND.md added (Next.js 16 + Server Components, lib/api/ conventions). → commit 58e96f8

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_id derived per-source at parse time, locked NOT NULL. → migration tracker, runbook

  • New apps/etl/src/loader/ module: CompanyResolverService, NodeResolverService (race-safe, alias-keyed), VacancyLoaderService (transactional upsert + vacancy_nodes rewrite), LoaderBackfillService, LoadVacancyActivity. vacancyPipelineWorkflow starts one child per extracted record; WorkflowIdReusePolicy.ALLOW_DUPLICATE_FAILED_ONLY enables 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, SKIP overlap, Europe/Kyiv). New rssIngestAllWorkflow fans out one child per source with parentClosePolicy: ABANDON. RSS_INGEST_INTERVAL_HOURS env var controls cadence.

  • Extraction made per-record best-effort via Promise.allSettled; failure count recorded in rss_ingests.error_message.

  • Activity retry policies normalized: 3 attempts, 5s/10s/20s backoff; finalizeIngest keeps 5 attempts.

  • POST /rss/extract-missing?limit=N added for synchronous backfill of un-extracted records.

  • Child workflow IDs changed to rss-ingest-<code>-<YYYY-MM-DDTHH-MM-SSZ>.

  • md/runbook/failure-recovery.md added. Suite: 13 suites / 48 tests.

3 травня 2026 (frontend import)

  • Frontend imported as @metahunt/web (apps/web/) from standalone metahunt-client repo (no history transfer). Vercel deploy approach switched to new project + sequential domain migration. Root package.json gained 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,…}), CORS origin: "*" for cross-origin dev access.

  • First apps/web → backend integration: lib/api/monitoring.ts typed fetcher, Server-Component Promise.all fetch, URL-driven filter state, /monitoring page with stat cards + RssRecordCard feed. 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-0004

  • OpenAI extractor and Zod re-validation removed; EXTRACTOR_PROVIDER ∈ {baml, placeholder}. BamlVacancyExtractor is one line: return b.ExtractVacancy(text).

  • Real-world fixture added: DOU.ua RSS item as TS module + BAML test block for prompt iteration.

  • Suite: 12 suites / 43 tests.

29 квітня 2026

  • RSS+Temporal port — T10–T13 (RssSchedulerService, RssController, RssModule wired into AppModule, workflow bundler fix). ingestAll() / ingestRemote() replace the legacy ingestAll(local). GET /rss returns 202 Accepted. autoStart gated on NODE_ENV !== 'test'. Suite: 12 suites / 43 tests. → migration tracker

  • dotenv.config() added to main.ts so pnpm start:dev resolves env without --env-file-if-exists.

  • GET /healthz added: parallel Postgres + MinIO + Temporal checks, 200 ok / 503 degraded. Railway healthcheckPath switched to /healthz.

  • Temporal Cloud support: TEMPORAL_API_KEY enables 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-init sidecar and temporalio/auto-setup + UI. Activities ported under TDD: RssFetchActivity, RssParseActivity, RssExtractActivity (introduced VacancyExtractor interface + OpenAI impl), RssFinalizeActivity, and the rssIngestWorkflow. Suite: 9 suites / 37 tests. → migration tracker

  • Extractor abstraction: VACANCY_EXTRACTOR token selects PlaceholderVacancyExtractor or OpenAiVacancyExtractor via LLM_EXTRACTION_ENABLED; future swap = new impl, no activity/workflow changes.

  • workflowsPath locked to resolve(__dirname, 'workflows'); rss/workflows/index.ts barrel 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-0001

  • apps/etl switched from headless createApplicationContext to a full HTTP server; GET / returns { greeting } as a cross-workspace DI canary. → ADR-0002

  • Dev scripts added: pnpm dev (parallel tsc -w + nest start --watch), start:prod, start:debug.

  • Engineering docs (md/) set up with Snapshot + Journal layout; package-level README.md added for each package.

  • @metahunt/database migrated from placeholder token to real Drizzle + Postgres provider (DRIZZLE) with schema, migrations, and seeds. Health endpoint verifies DB via SELECT 1.

  • Env behavior unified: node --env-file-if-exists=../../.env for local; process env primary everywhere.

  • Migration drift artifact 0004_purple_exodus removed; migration hygiene rule documented.

  • Railway IaC added: root Dockerfile (multi-stage, Node 22), railway.json with pre-deploy migrations, .dockerignore. SSH remote + git identity pinned to m4xx1k. → md/runbook/railway-deploy.md

  • Docker build hardened through three iterations: recursive workspace install in build stage, tsconfig.base.json copied to runtime, workspace node_modules for libs/database copied so ts-node resolves during pre-deploy migrations.

  • Railway watchPatterns, healthcheck path, and runbook operational rules finalized.