跳转至

Changelog

All notable changes to open-gerege-nexus (Gerege Nexus) will be documented in this file.

Entries below the rebrand keep the names that were true when they shipped — the open-gerege-mn-erp repository, the ERP framing, and the openerp.gerege.mn deployment, which has since moved to nexus.gerege.mn. A changelog edited to match the present tense stops being a record.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.


[Unreleased]

Added — ажиглалт нь OpenTelemetry дээр, өөрийн домэйнтэй

Хэмжүүрүүд client_golang дээр гараар бичигдсэн, нэр нь өөрсдийн зохиосон байсан. Одоо OpenTelemetry-ийн metrics SDK-аар дамжиж, Prometheus exporter-ээр хуучин /metrics дээрээ гарна. HTTP-ийн хоёр хэмжүүр semantic convention руу шилжив — http_requests_total ба http_request_duration_seconds нь http_server_request_duration_seconds болж нэгдсэн, хүсэлтийн тоо нь тэр гистограммын _count цуврал. Repo доторх бүх alert дүрэм, dashboard панел, консолын query хамт шилжсэн.

Гурван зүйл дайвар байдлаар зассан. net/http ямар ч token-ыг method болгож хүлээж авдаг тул хэн ч зохиомол verb илгээгээд хязгааргүй тооны цуврал үүсгэж чадах байсан — танихгүй method одоо _OTHER. resilience_in_flight_requests нь Set()-ээр бичигддэг байсныг UpDownCounter болгов: зэрэг дуусах хоёр хүсэлт агшны зургаа дурын дарааллаар бичиж, гарсан тоо хуучирдаг байв. Exemplar нь хоёр талаасаа тасарсан байсныг залгав — латенси графикийн удаан цэг дээр дарахад тэр хүсэлт ямар SQL хүлээснийг харуулна.

monitor.nexus.gerege.mn — Grafana, Alertmanager. Grafana нь платформын өөрийн OIDC-ээр нэвтэрнэ: roles scope дээр platform_admin claim гарах ба тэр нь эхний байгууллагын админд — тохиргооны шидтэн үүсгэсэн байгууллагын админд — л үнэн. Claim нь хүний тухай, токен аль workspace-д зориулагдсанаас хамаарахгүй.

Долоон dashboard: API тойм, Гадаад системүүд, Инфраструктур, Тэсвэрлэлт, Логууд, Аюулгүй байдал, Мониторингийн эрүүл мэнд. Самбар бүр лог руу орох цэстэй.

Trace асав. Tempo хоёр долоо хоног ажиллаж юу ч хүлээж аваагүй байсан — кодод бүх зүйл бэлэн байсан, .env рүү хувьсагч хүргэх зам байгаагүй.

Added — нөөцлөлт, эцэст нь

Энэ платформ дээр нөөцлөлт огт байгаагүй: cron суугаагүй, скрипт хостод байхгүй, platform_backups хүснэгт хоосон. Консолын Нөөцлөлт дэлгэц хоосон байсныг хэн ч анзаараагүй — хоосон дэлгэц нь «юу ч буруудаагүй»-тэй яг адилхан харагддаг.

backup.sh одоо үр дүнгээ хоёр газар бичнэ: platform_backups (консол уншина) ба node_exporter-ийн textfile (Prometheus уншина). Дөрвөн дохио, тэр дундаа NexusBackupNeverSeen — «нөөцлөлт унасан» биш, «нөөцлөлт байгаа эсэхийг хэн ч хэмжихгүй байна».

backups.nexus.gerege.mn — S3-той нийцэх сан. Dump нь хостыг орхихоосоо өмнө age-ээр шифрлэгдэнэ; эх хостод зөвхөн нийтийн түлхүүр байдаг тул эвдэрсэн платформ өөрийн илгээсэн зүйлээ уншиж чадахгүй. Bucket нь хувилбартай ба суулгацын түлхүүр устгах эрхгүй: гараас нь атгасан хост нэмж чадна, арилгаж чадахгүй. Сэргээлтийг хаях зориулалттай санд ажиллуулж баталгаажуулсан.

Added — баримт бичгийн сайт MkDocs дээр

docs.nexus.gerege.mn — MkDocs + Material for MkDocs, docs.gerege.mn-тэй яг ижил хэрэгсэл, ижил брэнд. Хуудасны жагсаалт нь docs/site/pages.mjs-ээс уншигдана: хоёр жагсаалт байвал салж, салсан нь нь хэн ч харахгүй байгаа нь болно.

Changed — эхний бүртгэл нь хүн биш

Тохиргооны шидтэн Gerege Core-оос хүнийг хайж, түүний нэрийг эхний бүртгэлд тавьдаг байсан. Эхний бүртгэл бол хүн биш — байгууллага үүсгэж, ажиллах хүмүүсээ урих хаалга. Нэр нь одоо тогтмол Super Admin, хүний хайлт шидтэнээс хасагдав. И-мэйл хэвээр: нууц үг сэргээх мессеж хэн нэгэнд хүрэх ёстой.

Fixed — Authenticator дээрх нэр брэндээ дагана

Нэг хост дээр ажиллаж буй бүх бүтээгдэхүүний консол утсан дээр яг ижил бичлэг үүсгэдэг байв — issuer кодод бэхлэгдсэн байсан. Одоо config.BrandName()-ыг дагана. Мөн url.Values.Encode хоосон зайг + гэж бичдэг тул утсан дээр Gerege+Nexus+Control+Plane гэж гардаг байсныг %20 болгов.

Fixed — засварын горим хүнийг байгууллага дотор нь хоръё гэж байв

Байгууллагыг засварын горимд оруулахад тэр байгууллагын гишүүн бүр — түүнийг асаасан админ ч мөн адил — өөр байгууллага руугаа шилжиж чадахгүй болдог байв. Шилжих нь POST, зөвхөн-унших хаалт бүх POST-ыг татгалздаг; гарах цорын ганц зам нь бүрмөсөн гарах байлаа.

Хаалтын өөрийнх нь тайлбар "гарах гэсэн хүн үргэлж гарч чаддаг байх ёстой, хүнийг хорьдог засварын горимыг хэн ч дахин асаахгүй" гэж бичсэн байсан ба гарахыг чөлөөлсөн — шилжихийг мартсан. Одоо хоёулаа чөлөөтэй: шилжилт нь засварлагдаж буй байгууллагын дотор юу ч бичдэггүй, зөвхөн session-ы мөрийг өөрчилдөг.

Мөн байгууллага солих цонх алдааны шалтгааныг харуулдаг боллоо: өмнө нь юу болсноос үл хамааран «дахин оролдоно уу» гэдэг байсан нь засварын горим ба гишүүнчлэлээ алдсан хоёрын аль алинд буруу зөвлөгөө.

Fixed — Google-ээр анх нэвтрэх нь нэвтрэх дэлгэц рүүгээ буцдаг байв

Google-ээр анх удаа нэвтрэхэд backend бүх зүйлээ зөв хийж байсан: хаягийг баталгаажуулж, домэйныг шалгаж, таних мэдээллийг парклаад /login/bind руу шилжүүлдэг. Гэтэл ажлын мужийн бүрхүүл тэр хаягийг нэвтэрсэн хүний дэлгэц гэж үздэг байв — PUBLIC_ROUTES нь яг таарах шалгалттай бөгөөд /login/bind түүнд ороогүй. Бүрхүүл /api/v1/me асууж, session байхгүй хүнээс 401 аваад /login руу буцаан түлхэнэ.

Гаднаас нь харахад: Google дарахад юу ч болохгүй. Алдаа ч алга — юу ч уначхаагүй, зүгээр л хүргэх гэсэн дэлгэц нь зурагдаагүй. eID-ээр нэвтэрсэн хүн профайлаас Google холбоход ажилладаг байсан нь (session байгаа тул бүрхүүл түлхэхгүй) энэ асуудлыг "Google login ажиллахгүй байна" биш "eID хийсний дараа л ажилладаг" мэт харагдуулж байв.

Ижил шалтгаанаар хоёр зам мөн эвдэрсэн байсан: урилга ба нууц үг сэргээх (/login/set-password — файл нь өөрөө "public route by necessity" гэж бичсэн), ба консолын impersonate handover (/impersonate — session нь тэр хуудсан дээр л үүсдэг).

Дүрэм нь одоо lib/publicRoutes.ts-д, нэвтрэх муж нь угтвараар: /login/** бүхэлдээ нээлттэй. Тодорхойлолтоороо зөв — тэнд байгаа дэлгэц бүр нэвтрээгүй хүнд зориулагдсан — бөгөөд ирээдүйд нэмэгдэх дэлгэц ижил алдаанд дахин орохгүй. /impersonate жагсаалтад нэмэгдэв.

Fixed — "бүртгэл алга" гэж хэлдэг байсан нь "дахин оролдоно уу" болов

binding_failed нь no_account нэрээр явж байсан. Хоёр нь эсрэг зөвлөгөө: нэг нь админ ажиллах ёстой гэнэ, нөгөө нь дахин оролдохыг хэлнэ. Бүртгэл нь байхгүй нь зөв — тэр яг одоо үүсэх гэж байсан бөгөөд зөвхөн баталгаажуулалтын дэлгэц рүү хүрч чадаагүй.

Added — frontend-ийн тестийг бодит болгов

Хорин долоон мянган мөр frontend дээр хоёр зуун есөн мөр тест байв — бүгд нь цэвэр функц ба эх кодын инвариант, өөрөөр хэлбэл компонентын дотор амьдардаг дүрэм бүр шалгагдахгүй: Node нь TSX-ийг хөрвүүлж чадахгүй тул тэдгээрийг шалгах арга байгаагүй. Дээрх нэвтрэлтийн алдаа хоёр долоо хоног амьдарсан нь яг үүний үр дагавар.

node --testvitest + Testing Library (jsdom). Байсан файлууд хэвээрээ, импортоо л сольсон. Нийт 144 тест: консолын арван зургаан дэлгэц рендерлэгдэж, эрхийн ялгаа (auditor бүгдийг уншаад юу ч дарж чадахгүй, operator байгууллага түдгэлзүүлж чадах ч устгал хүсэж чадахгүй, support дотор нь харж чадах ч амьдралын мөчлөгт хүрэхгүй), өөрийгөө идэвхгүй болгож чадахгүй, нэг удаагийн handover яг нэг удаа, step-up код асуугаад тасалсан үйлдлээ үргэлжлүүлэх — бүгд шалгагдана.

Гурван инвариант тест эх кодыг өөрийг нь уншина: Go-гийн capabilities хүснэгт ба консолын хуулбар зөрвөл унана; серверийн илгээдэг sso_error шалтгаан бүр эсвэл өгүүлбэртэй, эсвэл "зориуд ерөнхий" гэж нэрлэгдсэн байх ёстой; бүрхүүлийн нээлттэй замын дүрэм нь дээрх дөрвөн дэлгэцийг session-гүй зурдаг байх ёстой.

Мониторингийн дэлгэцүүдээс шалгагдаж буй гол дүрэм: тоо байхгүй нь тэг биш. Prometheus-гүй суулгацад гурван хэмжүүр зураас харуулна — 0.00% гэж бичих нь эрүүл суулгацтай яг адилхан харагддаг.

E2E — Playwright. jsdom-оор асууж боломгүй хоёр дүрэм: консол өөрийн хостоороо л үйлчилнэ (/login → 404), ба /cp бүрхүүл нь layout тул маршрутууд дамжин амьд үлдэнэ — гурван дэлгэц дамжсаны дараа ч cp.me() нэг л удаа дуудагдсан байхыг тоолж шалгана. API нь хөтөч дотор stub хийгддэг тул backend ч, өгөгдлийн сан ч хэрэггүй.

CI-д npm test (vitest) хэвээр, build-ын дараа npx playwright test нэмэгдэв; унасан ажиллагааны trace артефакт болж үлдэнэ. Docker болон CI-д PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1npm ci гурван хөтөч татахгүй.

Fixed — консолын зөвхөн дүрстэй товчнууд нэргүй байв

/cp/config-ийн флаг унтраах/асаах, флаг устгах, credential цэвэрлэх, тохиргооны түүх — дөрвүүлээ дүрс эсвэл агуулсан, aria-label-гүй товч байв. Дэлгэц уншигчид "button" гэж л дуудна, тестээс нэрээр нь олдохгүй. Дөрвүүлд нь одоо байгаа орчуулгын түлхүүрээс aria-label ба title өглөө.

Changed — нийтэлсэн үйлчилгээ өөрийн дэлгэцтэй боллоо

Байгууллагын хуудасны хамгийн доод хэсэгт наасан карт байсныг /organisation/services болгож, хажуугийн цэсэнд байгууллагынхаа доор оруулав. Хүн олж харах ёстой зүйл доод талын карт болж наагдсан бол хэн ч түүнийг шийдээгүй гэсэн үг: үйлчилгээ нийтлэх нь гадагш өгсөн амлалт — танихгүй хүн лавлахаас олж хандана — тул дээрхтэйгээ адил зэрэглэлтэй байх ёстой.

Fixed — «Дотор нь орж харах» нь хэнийг гэдгийг өөрөө сонгодог байв

Байгууллагын хуудасны үйлдлийн мөрөн дэх тэр товч members[0] —жагсаалт юугаар эхэлж таарсан тэр хүн— рүү ордог байсан. Оператор өөр хүний нэрийг шалтгаандаа бичээд огт өөр хүн болж ордог боломж; impersonation-ы бүх утга нь хэн болох дээр байтал.

Одоо тэр товч гишүүн бүрийн мөрөнд байна: хэнийг гэдгийг сонгож, шалтгаанаа бичнэ. Үйлдлийн мөрөөс хасав.

Fixed — хэмжигдээгүй систем ногоон гэж харагддаг байв

Гадаад системүүдийн самбар зургаан системийг «0.00% алдаа, ногоон» гэж харуулдаг байсан нь тэдгээрийг сайн ажиллаж байна гэсэн үг биш: Prometheus-д тэдгээрийн цуваа огт байхгүй байсан. instantQuery нь хоосон хариултыг тэг гэж уншдаг — тоолуурын хувьд зөв (хэн ч нэвтрээгүй бол нэвтрэлт үнэхээр тэг), гадаад системийн хувьд буруу: «алдааны хувь алга» гэдэг нь «алдаагүй» гэсэн үг биш.

Одоо instantSample нь дээж байсан эсэхийг хэлнэ. Дээжгүй систем ба хэмжүүр unknown төлөвтэй, measured: false-тай ирж, дэлгэц дээр «хэмжигдээгүй» гэж саарлаар гарна — жагсаалтаас алга болохгүй: eID хэмжигдээгүй байгаа нь eID байхгүй гэсэн үг биш, ажиллаж байгаа гэсэн үгээс бол огт өөр.

Мөн /cp/ops дэлгэц backend-ийн төлөвийг ok/warn гэж уншиж байсныг зассан — backend green/amber/red гэж хэлдэг тул мөр бүр улаанаар будагдаж, дотор нь "green" гэсэн үг англиар бичигдэж байлаа.

Fixed — админ болгох нь нэг товшилт байв

Хандалтын дэлгэцийн гишүүн бүрийн хажууд эрхийн чипүүд зэрэгцэн сууж, тэдгээрийн хамгийн хүчтэй нь бусадтайгаа адил амархан дарагддаг байв. nexus.gerege.mn дээр хоёр хүн — хүсэлт нь зөвшөөрөгдсөнөөс хойш хорин секундын дараа — дөнгөж орсон байгууллагынхаа админ болов; дэлгэц тэгэх гэсэн үү гэж асуугаагүй.

Одоо admin эрх өгөхийн өмнө хэнийг, юу болгож байгааг нэрлэсэн асуулт гарна. Бусад эрх өмнөх шигээ нэг товшилт; админыг хасах нь сүүлчийнх бол backend өмнөхөөрөө татгалзана.

Зөвшөөрлийн зам өөрөө зөв байсан — миграц 00008-ийн trigger шинэ гишүүнчлэлд зөвхөн user эрх өгдөг — гэхдээ энэ нь харагдахгүй тул тестээр бэхлэв: TestAnApprovedRequestGrantsOnlyTheSmallestRole.

Changed — оператор нэмэхэд нэрийг нь бүртгэлээс

Байгууллага нээхэд аль хэдийн хийсэн зүйлийг операторын цонхонд ч: регистрийн дугаараар Gerege Core-оос хайж (GET /api/platform/v1/directory/person) нэр, байвал и-мэйлийг нь татна. Бүртгэл нэрийг хэрхэн бичдэгээр нь бичнэ; гараар бичсэн нэр бол хэн нэгний галиглал.

Хайлт заавал биш: бүртгэлд байхгүй эсвэл и-мэйлгүй хүнийг өмнөх шигээ гараар бөглөнө.

Added — консол дээр «Хэрэглэгч» бүлэг: платформын бүх хүн

Дэмжлэгийн дэлгэц гурван тэмдэгт бичихийг шаардаж, нэг хүнийг олдог. Тэр нь "хэн нэг нь залгасан" гэдэгт зөв хэлбэр, харин хүн амын тухай асуулт бүрд буруу: хэдэн бүртгэл байна, хэд нь үнэхээр нэвтэрч чадах вэ, хэн нь ямар ч байгууллагад харьяалагдахгүй байна вэ. Эдгээрийг бүхэлд нь асуухаас өөр аргагүй.

/cp/people — бүх бүртгэл: дөрвөн тоо (нийт, eID-ээр баталгаажсан, нээлттэй session-той, байгууллагагүй), хайлт, шүүлтүүр, хуудаслалт. Тоонууд нь хуудасных биш, хүн амынх: хуудас гүйлгэхэд өөрчлөгддөг "баталгаажсан" тоо байхгүй тооноос дор.

/cp/people/[id] — нэг хүний бүх зүйл: нэвтрэх аргууд (eID ба холбогдсон провайдерууд, тус бүр хэзээ холбогдсон, сүүлд хэзээ харагдсан), харьяалагдах байгууллагууд эрхийнх нь хамт, яг одоо нээлттэй session-ууд, ба түүний нэрээр хэн, хэзээ, ямар шалтгаанаар орсон түүх.

Зөвхөн унших. Түгжээ тайлах, session таслах, буцаж орох холбоос илгээх нь дэмжлэгийн дэлгэцэд хэвээр — тэнд нэг хүнд, нэг шалтгаантай, нэг үйлдэл болж хийгддэг. Хоёулаа support.act эрхийн ард: §2.2-ын шугам хэвээр, оператор эрх хүмүүсийн мэдээлэл харахгүй.

Миграц 00100 нь консолын role-д registry.user_sso_identities дээр зөвхөн SELECT өгнө (00099 нь eID-ийнхийг өгсөн).

Added — байгууллагад админаас өөр хүн нэмэх

Байгууллагын хуудсан дээрх «Хүмүүс» хэсэгт Хүн нэмэх. Эхний админтай ижил жагсаалтаас — eID-ээр нэвтэрч баталгаажсан хүмүүсээс — сонгоно, шалтгаан ба хоёр дахь хүчин зүйл шаардана, audit-д мөр үлдэнэ.

Тэр хүн платформын хамгийн бага эрхтэйгээр («Хэрэглэгч») орно, тэр эрхийг энэ дэлгэц биш, схем өөрөө өгдөг: миграц 00008-ийн trigger нь шинэ гишүүнчлэл бүрт user эрхийг олгодог. Консол эрхээ сонгодог байсан бол схемийн аль хэдийн хариулсан асуултад хоёр дахь хариулт болох байсан бөгөөд тэр хоёр зөрөх өдөр консолынх нь чимээгүй ялах байв. Түүнээс дээшхи эрхийг байгууллагын өөрийнх нь админ, өөрийнхөө хандалтын дэлгэцээс өгнө.

Хатуу хэрэгжилттэй хэрэглэгчийн хязгаарт хүрсэн байгууллагад нэмэхээс татгалзана: консол хязгаарыг нэг дэлгэцэд харуулаад нөгөө дэлгэцээрээ түүнийг тойрч гарах арга байж болохгүй. Зөөлөн хэрэгжилт өмнөх шигээ анхааруулна.

Changed — байгууллага нээхэд дэлгэрэнгүйг нь бүртгэлээс, админыг нь eID-ээс

Хоёулаа өмнө нь бичигддэг байв. Формоос бичсэн нэр бол бараг зөв нэр — дутуу ХХК, өөр галиг — тэр "бараг" нь нэг компанийн хоёр бичлэг болдог. Бичсэн и-мэйл хаяг бол тэр хаяг руу явсан урилга л болохоос, тэр хүн мөн эсэх нь батлагдаагүй.

Одоо: регистрийн дугаараар Gerege Core бүртгэлээс хайж, нэр, албан ёсны нэр, богино нэрийг татна (GET /api/platform/v1/directory/organisation). Эхний админыг eID-ээр нэвтэрч баталгаажсан хүмүүсийн жагсаалтаас сонгоно (GET /api/platform/v1/directory/people) — нэр, и-мэйл, регистр, аль хэдийн хэдэн байгууллагад байгаа, хамгийн сүүлд хэзээ орсныг харуулна.

Сонгосон хүнд урилга илгээхээ болив: тэр хүнд бүртгэл ба орох арга нь аль хэдийн байгаа тул урилга бол эхнийхийнх нь өмнө зогсох хоёр дахь хаалга. admin_user_id өгөгдөөгүй үед хуучин урилгын зам хэвээр.

eID-ээр баталгаажаагүй хүний id өгвөл татгалзана — сонгох гэдгийн учир нь яг тэр. Миграц 00099 нь консолын role-д registry.user_eid_identities дээр зөвхөн SELECT өгнө: иргэн ба бүртгэлийн холбоосыг нэвтрэлт нь бичдэг, консол хэзээ ч биш.

Added — консол дээр Tenant удирдлага апп

Rail-ын гурав дахь хавтас. «Байгууллага» бүлэг бүтнээрээ консолын аппаас энэ рүү нүүв — Байгууллагууд, Дэмжлэг, Хүлээгдэж буй зөвшөөрөл — учир нь оператор өдрөө тэнд өнгөрөөдөг, консолын бусад бүлгүүд нь очиж үздэг зүйл.

Хоёр шинэ дэлгэц, хоёулаа нэг байгууллагын хуудас хариулж чаддаггүй асуултад:

  • /cp/quotas — аль байгууллагад ямар хязгаар тавигдсан, хэдэн хүнтэй нь зэрэгцүүлээд, хэрэгжилт нь зөөлөн үү хатуу юу. Хязгааргүй байгууллагууд дээр нь тоологдоно; хязгаар тавих нь байгууллагынхаа хуудсанд, шалтгаантай хэвээр.
  • /cp/installations — аль апп аль байгууллагад, ямар хувилбартай. Каталогийн дэлгэц "гурав нь 1.2.0, нэг нь 1.1.0" гэж тоолдог; энэ нь тэр нэг нь хэн болохыг хэлнэ, мөн аппыг хасах санал гарсан өглөө "хэн үүнийг ашиглаж байна вэ"-д хариулна.

Fixed — операторын тест бусад тестийн бүртгэлийг унтраадаг байв

TestTheLastSuperadminIsKept нь сүүлийн ерөнхий админы хамгаалалтыг батлахын тулд бусад бүх ерөнхий админыг үнэхээр идэвхгүй болгодог байсан — тэр сан нь бүх багцын тесттэй хуваалцсан тул тэдний бүртгэлийг ч хамт унтраана. Одоо буцаагддаг транзакц дотор хийгдэж, гадаад ертөнцөд хүрэхээ болив.

Fixed — дахин үүсгэсэн өгөгдлийн сан эрхээ буруу role дээр авдаг байв

Role нь кластерынх, өгөгдлийн сан нь тийм биш. 00079 нь gerege_nexus_appgerege_nexus_tenant нэршилтийг зөвхөн tenant role байхгүй үед хийдэг тул нэг кластерт хоёр дахь сан үүсгэхэд 00079-өөс өмнөх бүх grant ба RLS policy хуучин role дээр буудаг; платформ нь gerege_nexus_tenant-аар SET ROLE хийдэг. Нэвтрэлт болно, дараагийн дэлгэц бүр 500: permission denied for table users.

Онолын зүйл биш: nexus.gerege.mn-ийг 2026-08-29-нд шинэ суулгацын урсгал турших зорилгоор хоосноос босгоход яг ийм болов — хуучин role дээр 220 эрх, шинэ дээр 11.

Миграц 00098 нь хуучин role-ийн барьж байгаа бүхнийг (хүснэгт, схем, sequence, RLS policy, default privilege) шинэ рүү зөөж, дараа нь хуучин role-ыг кластераас гаргана — тэгснээр дараагийн сан "эхнийх" болно. Мөн:

  • search_path дахин баталгаажуулав. 00084 үүнийг өгөгдлийн санд тавьдаг ч pg_dump нь ALTER DATABASE-ийн тохиргоог дампдаа авчирдаггүй; дампаас сэргээсэн суулгац бүх хүснэгттэй, search_path-гүй үлдэж, схем нэрлээгүй асуулга (хүнд гэр үүсгэх зам дахь roles) relation "roles" does not exist гэж унадаг байв.
  • Консолын хувилбарын самбарт grant өглөө. public.goose_db_version дээр operator role-д эрх огт байгаагүй тул нүүр хуудас бүр permission denied for table goose_db_version гэж чимээгүй анхааруулж, схемийн дугаарын оронд хоосон харуулж байв.

Гурван guardrail тест нэмэв: tenant role нь бүрхүүлийн асуудаг хүснэгтүүдийг уншиж чадах, өгөгдлийн сан search_path-аа барьж байгаа, консол миграцын хувилбарыг уншиж чадах.

Fixed — GEREGE_CORE_TOKEN deploy-д огт дамждаггүй байв

Регистрийн дугаараар байгууллага, хүн хайх нь энэ токеноор ажилладаг ба credentials.keys-д, клиент код, шидтэний тайлбар дотор бүгд нэрлэгдсэн байсан ч байрлуулалтын гурван газрын нэгд нь ч байгаагүй: compose нь backend рүү дамжуулдаггүй, deploy нь .env-д бичдэггүй, жишээ файлд ч алга. Гараар .env-д нэмсэн ч compose-оор дамжихгүй, дараагийн deploy-д арчигдана — өөрөөр хэлбэл шидтэн production дээр регистрээр хайх боломжгүй, шалтгаан нь харагдахгүй.

Гурвуулаа зассан: compose-ийн passthrough, deploy-ийн env/envs/.env heredoc, ба deploy/.env.prod.example дахь тайлбартай мөр.

Added — консолоос оператор нэмэх (CP-2)

Хоёр дахь операторт хүрэх цорын ганц зам нь production хост дээрх shell — cmd/operator-bootstrap, өгөгдлийн сангийн эзний эрхээр — байв. Эхний account-д зөв хяналт, дөрөв дэх нь буруу: платформ ба түүнд нэгдэх хүн бүрийн хооронд серверийн shell зогсдог, ийм шалтгаанаар өгсөн shell буцаж цуглуулагддаггүй.

/cp/operators дэлгэц: оператор нэмэх, эрх солих, идэвхгүй болгох/эргүүлэх, ба өөрийн нууц үгээ солих. Нэмэлт бүр ерөнхий админ (operator.write), хоёр дахь хүчин зүйл, шалтгаан шаардана, audit-д мөр үлдээнэ.

Шинэ account нь нэвтэрч чадахгүй төлөвт үүснэ: консол нууц үг ба authenticator-ийн түлхүүрийг нэг удаа харуулж, шинэ оператор кодоо оруулж баталгаажуулах хүртэл totp_confirmed_at хоосон. Хулгайлагдсан консолын session account үүсгэж чадах ч, тэр account нэвтэрч чадахгүй.

Миграц 00097 нь консолын DB role-д INSERT өгнө — 00049 үүнийг "CP-2-д хэрэгтэй болно" гэж тэмдэглэн зориуд түдгэлзүүлсэн байсан. DELETE хэвээр өгөгдөөгүй: явах ёстой операторыг идэвхгүй болгоно, audit-ийн мөрүүд оршсоор буй мөр рүү заасаар байна.

Хамгаалалт: сүүлийн (нэвтэрч чадах) ерөнхий админыг идэвхгүй болгож ч, эрхийг нь бууруулж ч болохгүй; хэн ч өөрийгөө идэвхгүй болгож, эрхээ өөрчилж чадахгүй.

Fixed — консол цэс дарах бүрд бүхэлдээ дахин зурагддаг байв

<Console> хүрээ хуудас бүрийн дотор байсан тул /cp дотор шилжих бүрд бүхэл бүрхүүл дахин mount хийгдэж: ачаалалтын төлөв анивчиж, cp.me() дэлгэц тутамд дуудагдаж, толгой/rail/самбарын төлөв (нээлттэй бүлгүүд ч) тэглэгдэж байв.

Хүрээ нь одоо app/cp/layout.tsx дотор. Layout нь доорх маршрутуудын хооронд шилжихэд амьд үлддэг тул зөвхөн ажлын муж дахин зурагдана — Next-ийн App Router энэ зорилгоор layout-тай. Арван долоон хуудас өөрсдийн агуулгаа л буцаадаг боллоо.

Added — консол дээр System Operations апп

Консолын rail хоёр дахь хавтантай боллоо. Эхнийх нь платформ дээр юу байгааг удирддаг (байгууллага, тохиргоо, аудит); хоёр дахь нь байрлуулалтыг өөрийг нь ажиллуулна — 3 цагт асуудаг гурван асуулт: ажиллаж байна уу, юу үйлдвэрлэгдэж байна вэ, юу хадгалагдаж байна вэ.

Хяналт/cp/ops (API-ийн гурван тоо, дэд бүтцийн хэмжүүрүүд, гадаад системүүд), /cp/ops/alerts (асаж буй сэрэмжлүүлэг ба тохиргооны анхааруулга — хоёр өөр зүйл, нэг мөчид уншигддаг), /cp/ops/jobs (өөрөө ажиллах ёстой ажлууд ба давтан алддаг байгууллагууд).

Тайлан/cp/ops/usage (бүх байгууллагын энэ сарын хэрэглээ; тоолуур бүр өөрийн утгаараа: тоологддог нь нийлбэр, хадгалалт нь сүүлийн заалт, идэвхтэй хэрэглэгч нь сарын оргил — өдрийн идэвхтэй хэрэглэгчийг нэмбэл нэг хүнийг гуч удаа тоолно), /cp/ops/schedules (аль хуваарь унасныг нэрлэнэ; нүүр хуудас тоог нь л хэлдэг).

Нөөцлөлт/cp/ops/backups (нөөц ба сэргээлтийн туршилтын түүх, гараар сэргээлт бүртгэх). Нүүр хуудас сүүлийнхийг л харуулдаг нь "өнөө шөнө болсон уу" гэдэгт хариулдаг; түүх нь "унаж байсан уу" гэдэгт хариулна.

Fixed — консол товлосон тайлангуудыг огт уншиж чаддаггүй байв

observability.backgroundJobs нь workspace.report_schedules-ыг асуудаг ч консолын DB role-д тэр хүснэгт дээр эрх байгаагүй. Алдаа нь warning болж залгигдаж, нүүр хуудсанд эрүүл харагддаг самбар үлдэж байв — яг тэр самбарын барихаар бүтээгдсэн уналт. Миграц 00096 нь SELECT эрх ба operator policy өгнө.

Fixed — хуваалцсан тест сангийн улмаас CI санамсаргүй улаан болдог байв

TestARequestUsesCachedControlDecisionsWhenTheirTablesAreUnavailable нь долоон хүснэгт дээр ACCESS EXCLUSIVE авдаг — тэр нь тестийн санаа. Гэвч go test ./... багцуудыг зэрэг ажиллуулж, бүгд нэг Postgres руу заадаг тул өөр багц registry.tenants-ийн мөр барьж байхад deadlock үүсдэг, Postgres хоёрын нэгийг алдаг — 2026-08-29-нд SQL огт өөрчлөөгүй хоёр салбар дээр яг үүнээс болж CI улаан болов (нэг нь main дээр deploy-г зогсоов).

Түгжээг lock_timeout-той болгож, алдвал транзакцаа дахин эхлүүлнэ (таван оролдлого). Тестийн баталгаа хэвээр: хүснэгтүүд үнэхээр хүрэшгүй болсныг шалгасаар байна.

Changed — AI туслах ба и-мэйл баталгаажуулалт консол руу нүүв

Хоёулаа платформын хөрөнгө байсаар атлаа ажлын муж дотор, байгууллага тус бүрд харагдаж байв. Туслахын нийтлэг prompt ба мэдлэгийн сан нь бүх байгууллагад үйлчилдэг — нэг tenant админ түүнийг засах нь бусад бүх байгууллагын хариултыг засаж байсан гэсэн үг. Баталгаажуулалтын үйлчилгээ нь нэг түлхүүр, нэг провайдертай тул "ажиллаж байна уу", "хэнд бичсэн бэ" гэсэн хоёр асуулт нь платформынх; tenant админ хариултын дөрөвний нэгийг л хардаг байв.

Одоо консол дээр: /cp/assistant (нийтлэг prompt, мэдлэгийн сан — бичилт бүр шалтгаантай, audit-д), /cp/email-verification (бүх байгууллагын бүртгэл, үйлчилгээний төлөв — зөвхөн унших). Ажлын мужаас /settings/ai, /settings/email-verification хуудсууд ба /api/v1/admin/ai/*, /api/v1/admin/email-verification/overview маршрутууд устав.

Миграц 00095 нь консолын DB role-д тухайн хүснэгтүүд дээр эрх ба RLS policy өгнө: prompt/мэдлэгийн хувьд зөвхөн tenant_id IS NULL мөрүүд (тиймээс оператор нэг байгууллагын өөрийн prompt-ыг уншиж ч, бичиж ч чадахгүй), бүртгэлийн хувьд зөвхөн SELECT. Мөн нийтлэг prompt түлхүүр бүрт нэг мөр байхыг баталгаажуулсан partial unique index нэмэв — UNIQUE (tenant_id, prompt_key) нь NULL-ыг ялгаатай гэж үздэг тул хоёр "глобал scope" зэрэгцэн орж болдог байсан.

Changed — толгойн нэр бүтэн, аватар хоёр үсэгтэй

UserMenu-ийн дугуй тэмдэг нэг үсгээс хоёр болов (хоёр үгтэй нэрээс үсэг тус бүр, нэг үгтэй нэрээс эхний хоёр үсэг), нэр нь 9rem дээр таслагдахаа больж бүтнээрээ харагдана. Ажлын муж ба консол хоёулаа ижил цэс хэрэглэдэг тул хоёуланд нь хүрнэ.

Changed — консолын толгойд бүтээгдэхүүний өөрийнх нь бүртгэлийн цэс

"Гарах" нь бар дээр ганцаараа зогсох товч байхаа болиод, ажлын мужийн UserMenu-ийн дотор оров: нэр, и-мэйл, доор нь эрх, дараа нь хэл ба загвар (гэрэл / харанхуй / систем), хамгийн доор нь Гарах. Операторт өмнө нь консол дотроос хэл, өнгөө солих арга огт байгаагүй.

UserMenu нь гурван нэмэлт заавал бус prop авна — showTenants, links, subtitle. Консол нь showTenants={false} (байгууллагын session байхгүй тул жагсаалт ч, түүний API дуудлага ч утгагүй), links={[]} (консолд /profile, /settings хуудас байхгүй), subtitle-ээр эрхээ өгнө. Анхдагч утгууд нь ажлын мужийн өмнөх зан төлөв яг хэвээр.

Changed — операторын консол admin.nexus.gerege.mn дээр нүүв

cp. гэдэг товчлолыг хүн уншиж ойлгодоггүй: консолын хаяг өөрөө юу болохоо хэлэх ёстой. Хост admin.nexus.gerege.mn болов — vhost, гэрчилгээ, CONTROL_PLANE_HOST репо хувьсагч, prod .env гурвуулаа шинэ нэр дээр.

cp.nexus.gerege.mn нь гэрчилгээгээ хадгалж, зөвхөн 301-ээр шинэ нэр рүү заана — хуучин bookmark ажиллах боловч консол тэр хостоос үйлчлэхээ болив. API-ийн HostGate нь хуучин нэрээр ирсэн хүсэлтэд 404 өгнө, өөрчлөлт шаардлагагүй: тэр хаяг ердөө CONTROL_PLANE_HOST-той таарахаа больсон.

Changed — консол ажлын мужийн бүрхүүлийг яг хуулж авав

Толгой хэсэг мөр мөрөөрөө ижил боллоо: брэндийн нүд, контекст, цэсний товч + бүх бүлгийг хумих, session цэг дээр операторын нэр, хайлт, баруун талд эрх ба гарах. Зүүн цэс ажлын муж шиг хоёр хуваалттай — 4rem-ийн апп rail ба 14rem-ийн модулийн самбар, хумигддаг бүлгүүдтэй, гар утсан дээр drawer болдог. Ижил class-ууд тул design-ий өөрчлөлт хоёуланд нь хүрнэ.

Layout.tsx-ийг хуваалцаагүй: тэр нь mount дээр /api/v1/me асуудаг — операторт байхгүй session. Хуваалцаж буй зүйл нь stylesheet.

Fixed — модулийн байнгын ажил миграцынхаа өмнө эхэлдэг байв

StartBackgroundJobs нь v1.15.0-д бүртгэгдсэн модуль бүрийн StartHousekeeping-ийг дууддаг болсон боловч жагсаалтын эхэнд байрлаж байв — модулийн миграцыг ажиллуулдаг ApplyCatalogToInstallations нь тэр функцийн төгсгөлд. Хүснэгтээ энэ хувилбараар авсан модуль эхний шүүрдэлтээ байхгүй схем дээр хийнэ.

client.gerege.mn-ий v1.15.0 rollout дээр Өртөөгийн суваг эхний дамжлагадаа relation "urtuu_peers" does not exist гэж зургаан удаа бичив, каталогийн шүүрдэлтээс 600мс өмнө. Өөрөө эдгэрдэг — дараагийн тик ажиллана — гэхдээ логоос харахад deploy унасантай ялгагдахгүй, ба энэ нь суулгац бүрийн эхний ачаалалт дээр давтагдана.

Дараалал зассан; TestAModulesHousekeepingStartsAfterItsMigrations нь өөрийн миграцтай модуль бүртгээд StartHousekeeping дотроосоо хүснэгтээ асуудаг.


[1.15.0] - 2026-08-27

Нэг сэдэв, хоёр хагас: нэршил ба хил. Эхнийх нь кодоос хоёр үгийг гаргав — platform, tenant — хоёр дахь нь цөмөөс хоёр бүтээгдэхүүнийг. Хооронд нь хүний хувийн муж («гэр») суув: гишүүнчлэлгүй хүн нэвтэрч, байгууллагаас хүсэлтийнхээ төлөвийг уншиж, өөрөө нэгдэх хүсэлт гаргадаг болов.

⚠️ v1 дотор эвдэрсэн өөрчлөлт. pkg/nexus-ийн экспортолсон гадаргуугаас нэрс хасагдаж, дахин нэрлэгдсэн. Semver-ээр бол энэ нь major боловч Go нь v2+ дээр модулийн замыг .../backend/v2 болгохыг шаарддаг — тэр нь найман distribution бүрийн бүх import-ыг өөрчилнө. Тиймээс v1 дотор гаргаж, distribution бүр go.mod-ын нэг мөр ба нэршлийн mechanical rename хийнэ. Wire format (tenant_id гэх JSON тэмдэглэгээ) өөрчлөгдөөгүй: web shell, дөрвөн native клиент, гадны OAuth2 клиент бүр хуучин шигээ уншина.

Changed — ⚠️ нэршил: platform ба tenant гэдэг үг кодоос гарлаа

platform гэдэг нэр гурван өөр зүйлийг нэрлэж байв — операторын урсгал, өгөгдлийн сангийн schema, процессыг угсардаг host. tenant нь байршлын үг бөгөөд байгууллагыг заах домэйний үгийн байрыг эзэлж байв (ADR 0001). Хоёулаа одоо зөвхөн прозод үлдэв:

Юу Байсан Болсон
Операторын урсгал internal/platform internal/operator
Процесс угсрагч pkg/platform pkg/host
Байгууллагын урсгал internal/tenant internal/workspace
DB schema platform registry + operator (27 хүснэгтийг хоёр эх сурвалжаар хуваав)
DB schema tenant workspace
SDK TenantID, RequireTenant, AllowedTenants, TenantOf WorkspaceID, RequireWorkspace, AllowedWorkspaces, WorkspaceOf

api.txt-ийн 554 мөрөөс 65 нь дахин бичигдсэн, нэг ч тэмдэглэгээ нэмэгдээгүй, хасагдаагүй. Нэрлэлтийг gopls rename-ээр хийсэн тул TenantID нэртэй боловч SDK-гийнх биш дотоод бүтцүүд хөндөгдөөгүй. pkg/platform нь эхлээд дамжуулагчаар үлдэж, дараа нь хасагдав.

Distribution-д хийх ажил: go.mod-ыг энэ хувилбар руу зөөж, nexus.RequireTenantnexus.RequireWorkspace маягийн mechanical rename. client-gerege-nexus дээр 72 дуудалт байв.

Added — хүн өөрийн мужтай болов («гэр»)

Өмнө нь гишүүнчлэлгүй хүн нэвтэрч чаддаггүй байв: схем нь хүнийг байгууллагаас хамааралгүй гэж үздэг атал нэвтрэлтийн код хориглодог — схемийн зөвшөөрснийг код хориглож буй цорын ганц цэг. EID_JIT_TENANT_SLUG гэсэн тойрч гарах зам нь иргэнийг хэн ч шийдээгүй байгууллагад гишүүн болгодог байв.

  • Гэр нь гуравдахь plane биш, муж мөр (ADR 0006). Шинэ schema, шинэ role, шинэ GUC байхгүй: app.current_tenant иргэний session дээр ҮРГЭЛЖ тавигдана.
  • person_items — проекц, агуулга биш (00086). Нийлүүлэгч даалгаврын төлөвөө иргэний гэрт нийтэлнэ (nexus.PersonFeed); баримт, нотолгоо, хувийн мэдээлэл нийлүүлэгчийн мужид үлдэнэ. Байгууллага дамнасан уншилт огт үүсэхгүй.
  • /me/items, /me дэлгэцүүд. Хоёр дахь бүрхүүл биш — гэрт байгаа хүнд байгууллагын дэлгэцүүд харагдахаа болино.
  • Нэгдэх хүсэлт (00089): хүн байгууллагад өөрөө хүсэлт гаргана, цөм person_items рельсээ өөрөө ашигладаг болов — «half a capability is none»-ыг давтахгүйн тулд.
  • Үйлчилгээгээр нь байгууллагыг олох лавлах: иргэн «жолооны үнэмлэх сунгуулах» гэдгээ мэднэ, хэн хийдгийг нь мэдэхгүй.
  • Кодын хувьд internal/person домэйн (route, схем, зан төлөв хөдлөөгүй).

Removed — ⚠️ Өртөө ба гадаад холбогч цөмөөс бүрмөсөн гарлаа

Хоёр бүтээгдэхүүн, нэг шалтгаан. Аль аль нь өмнө нь хагасаараа гарсан байв: дэлгэц, амьдралын мөчлөг нь client-gerege-nexus руу явж, «рельс» гэж нэрлэгдсэн хэсэг нь цөмд үлдсэн. Тэр заагийг барьсан үндэслэл нь CORE_BOUNDARY_PLAN.md §4.1-ийн шалгуур: «нэг суулгац дотор үүнээс хоёр зэрэг оршиж чадах уу». Суваг ч, шифр ч чадахгүй — тиймээс рельс.

Шалгуур зөв, гэхдээ дутуу байсан. «Хоёр байж болохгүй» гэдэг нь «цөмд байх ёстой» гэсэн үг биш: хэрэглэгч нь ганц апп бол «ганц байх» гэдэг нь тэр аппын дотор ганц байна гэсэн үг. Гурван сарын дотор nexus.Link ба nexus.PeerDirectory-г Өртөөгийн самбараас өөр юу ч дуудсангүй, экспортыг esign рельсээс өөр юу ч дуудсангүй, nexus.MeetingBooker-ыг юу ч дуудсангүй. Тиймээс шалгуурт хоёр дахь асуулт нэмэгдэв: хоёр өөр бүтээгдэхүүн үүнийг өнөөдөр дуудаж байна уу?

Өртөө (00087) — тээвэр (internal/workspace/urtuu), дугтуйн гэрээ (pkg/urtuu), nexus.Link, nexus.PeerDirectory болон сувгийн зургаан хүснэгт бүгд явлаа. Цөмд Өртөөгийн юу ч үлдсэнгүй: маршрут ч, /.well-known/urtuu.json ч, URTUU_*/RING_* хувьсагч ч, операторын консолын «Өртөө» гэрэл ч. Аппын хувилбар: io.gerege.nexus.urtuu 1.1.0.

Гадаад системийн интеграц ба webhook (00088) — менежер, провайдерын бүртгэл, OAuth солилцоо, илгээх давталт, гурван хүснэгт бүгд явлаа. nexus.MeetingBooker гэрээ устлаа. Аппын хувилбар: io.gerege.nexus.integrations 1.1.0.

Added — nexus.SecretSealer

Цөмд үлдсэн ганц зүйл нь шифр: INTEGRATION_ENCRYPTION_KEY ба түүний AES-GCM нь internal/kernel/security-д хэвээр бөгөөд гадагш энэ гэрээгээр гарна. Энэ нь шалгуурын хоёр асуултыг хоёуланг нь давсан цорын ганц хэсэг: нэг суулгацад хоёр шифр байж болохгүй, бөгөөд итгэмжлэл хадгалдаг аль ч модуль түүнийг дуудна. Хувьсагчийн нэр өөрчлөгдөөгүй — тэр нь ижил түлхүүр.

Мөн модуль өөрийн байнгын ажлаа зарлах зам нэмэгдэв: бүртгэгдсэн модуль StartHousekeeping(context.Context) метод агуулбал платформ түүнийг эхлүүлнэ. Өмнө нь apps.Bootstrap-ийн жагсаалт л энэ боломжтой байсан бөгөөд тэр нь цөмийн дотоод — Өртөөгийн солилцооны давталт гадаад репод очиход энэ нь зайлшгүй болов.

Changed — ⚠️ зан төлөв өөрчлөгдсөн гурван зүйл

  1. Гарын үсэг зурсан баримт гадагш автоматаар илгээгдэхээ болив. POST /api/v1/esign/documents/{id}/export алга болов. Экспортын код нь холбогчийн аппын дотор байгаа тул хэрэгтэй өдөр нэг дуудлагын зайд байна.
  2. Апп суулгаагүй суулгац Өртөөгийн дугтуй хүлээж авахаа болив. Өмнө нь аппгүй ч дугтуй ирж, шалгагдаж, хадгалагдаж, апп суулгагдмагц уншигдаж байсан.
  3. Демо суулгацын integrations апп жагсаалтаас хасагдав (pkg/host/seed.go).

Changed — CI нь downstream repo-г clone хийхээ болив

Платформ өөрийн эзэмшдэггүй репог шинэчилж push хийх хүртэл merge хийж чадахгүй байсан — хамаарлын чиглэл урвуу. Гэрээний эвдрэлийг api.txt ба testdata/canary (тусдаа Go модуль тул go build ./... түүнд хүрэхгүй, CI л хүрнэ) хоёр барина.

Migration — амьд суулгац дээр юу хийх вэ

Энэ хувилбар нь арван нэгэн миграц (0007900089) агуулна, тэдгээрийн хоёр нь бүх хүснэгтийг schema хооронд зөөнө (platformregistry+operator, tenantworkspace) ба нэг нь DB role-ыг gerege_nexus_appgerege_nexus_tenant болгож нэрлэнэ. Аль аль нь ажиллаж буй суулгац дээр шалгагдсан (nexus.gerege.mn, 2026-08-27).

Модулийн миграц бичдэг distribution бүр анхаараарай: role-ыг нэрээр нь бичсэн бол цэвэр өгөгдлийн сан дээр role "gerege_nexus_app" does not exist гэж унана. pg_roles-оос хайж, gerege_nexus_tenant-ыг эхэнд тавь.

00087 ба 00088 нь есөн хүснэгтийг CASCADE-аар хаяна. 2026-08-27-нд гурван суулгац дээр тоолоход бүгд 0 мөртэй байв (nexus.gerege.mn дээрх хоёр холбогч нь INACTIVE, хэзээ ч холбогдож байгаагүй). Өгөгдөлтэй хост дээр appstore-ийн store_* хүснэгтүүдэд ажилласан алхмыг давт: миграцаас өмнө CREATE TABLE <нэр>_keep AS SELECT * FROM workspace.<нэр>, rollout, дараа нь гадаад түлхүүрийн дарааллаар буцааж INSERT. Модулийн миграц нь хүснэгтүүдийг IF NOT EXISTS-ээр дахин зарлах бөгөөд платформ суулгацын үед бус ачаалахдаа модулийн схемийг шүүрддэг (v1.10.1-ээс хойш), тиймээс гар засвар шаардлагагүй.

Distribution-ууд: pkg/urtuu, pkg/nexus.Link, PeerDirectory, MeetingBooker дөрөв алга болсон нь эвдэрсэн өөрчлөлт. Эдгээрийг хэрэглэдэг цорын ганц repo нь client-gerege-nexus бөгөөд тэр нь энэ өөрчлөлттэй хамт шинэчлэгдсэн.


[1.14.1] - 2026-08-26

Fixed — өөр байгууллагын клиентэд өгөх анхны зөвшөөрөл бүтдэггүй байв

Зөвшөөрлийн дэлгэцийн ард байх /api/v1/oauth2/consent нь клиентээ дуудагчийн байгууллагад уягдсан холболтоор уншиж байсан тул мөрийн түвшний хамгаалалт өөр байгууллагын бүртгэсэн клиентийг нуудаг байв. Үр дүнд нь unauthorized_client: unknown or disabled client буцаж, байгууллага хоорондын анхны нэвтрэлт хэзээ ч гүйцэддэггүй: клиентээ бүртгэсэн байгууллагын гишүүд ороод, бусад нь орж чаддаггүй.

Клиент нэг байгууллагынх, нэвтэрч буй хүн өөр байгууллагынх байх нь зохиомжийн санаа — issueAuthCode нь кодод клиентийнх биш хэрэглэгчийн байгууллагыг хадгалдаг нь яг үүний төлөө. Нэвтрэлтгүй ажилладаг /oauth2/auth аль хэдийн платформын замаар уншдаг байсан; одоо consent хоёр ч мөн адил уншина.

TestConsentPromptFindsAnotherTenantsClient нь dbguard-тай усан сан дээр хоёр байгууллага үүсгэж, засваргүйгээр production-ий яг тэр 400-г давтдаг.


[1.14.0] - 2026-08-25

Changed — ⚠️ PlatformVersion-ийг стампдах -X зам өөрчлөгдөв

internal/platform/server.go-д байсан PlatformVersion нь v1.13.0-ийн дараа internal/kernel/config-т нүүсэн (5471576). Distribution-ий Dockerfile-ууд хуучин замаар стампддаг бол юу ч болохгүй: Go нь байхгүй тэмдэгт рүү чиглэсэн -X-ийг чимээгүй үл тоомсорлодог тул образ өөрийгөө 1.1.0 гэж хэлээд, өөрийнхөө каталогийн manifest бүрийг татгалзаж, асахаа болино.

-X .../backend/internal/platform.PlatformVersion=${version}        # үхмэл
-X .../backend/internal/kernel/config.PlatformVersion=${version}   # зөв

v1.14.0 бол энэ нь мэдрэгдэх эхний хувилбар. Бумп хийхийн өмнө deploy/Dockerfile-аа засах хэрэгтэй — appstore-gerege-nexus, sso-gerege-nexus хоёр 2026-08-25-нд яг үүнийг хийсэн.

Removed — SSO клиентийн апп цөмөөс гарч, App Store-д очлоо

internal/apps/sso_clients нь энэ репогийн сүүлчийн апп байв. Одоо appstore-gerege-nexusmodules/ssoclients болж, каталогоор дамжин аль ч distribution түүнийг апп стороос татаж суулгана — эхнийх нь sso-gerege-nexus.

Үүний дараа catalog/apps.json ба internal/apps хоёулаа хоосон. Энэ нь дутуу байдал биш: ECOSYSTEM_GIT_STRATEGY-ийн тавьсан "бизнес апп огт байхгүйгээр асдаг платформ" шалгуур ингэж биеллээ. Аппын дэлгэц, migration history нь shell-д үлдсэн бөгөөд module-гүйгээ амьгүй (§2.3-ын дүрэм, contacts-тай адил) — иймд цөмийн frontend image-ийг хуваалцдаг distribution бүр дэлгэцээ хэвээр авна.

Рельс нь цөмийнх хэвээр. OAuth2 сервер — код олгох, токен солих, id_token гарын үсэглэх, клиент хадгалах, redirect шалгах — бүгд internal/tenant/ssoprovider-т үлдэв. Апп нь тэр рельс рүү хорин экспортолсон нэрээр биш, шинэ гэрээгээр хүрнэ.

Added — nexus.SSOClientRegistry

Модулийн SDK-д OAuth2 клиентийн бүртгэлийн гэрээ нэмэгдэв: SSOClientRegistry interface ба SSOClient, SSOScope, SSOClientActivity, SSOConsent, SSOSigningKey төрлүүд, ErrSSOClientNotFound sentinel.

Цөм нь nexus.Provide[nexus.SSOClientRegistry](https://github.com/gerege-systems/open-gerege-nexus/blob/main/ssoprovider.AsClientRegistry(...)) гэж нийтэлнэ. Ямар ч distribution nexus.Capability[nexus.SSOClientRegistry]() гэж асуугаад өөрийн суулгацын провайдерыг администрацлах апп агуулж чадна.

Нууц үгийн digest гэрээгээр гардаггүй: CreateClient/RotateClientSecret нь задгай нууцыг авч, hash-ыг цөм өөрөө хийнэ — модуль тухайн суулгацын hash функцийг мэдэх шаардлагагүй.

Changed — Консолыг хаягаар хязгаарлах нь зөвхөн платформ ХААЛТТАЙ үед

Консол нь nginx-ийн deny all allowlist-ын ард байсан бөгөөд тэр хаалт нь платформын өөрийнх нь тохиргоог харж чаддаггүй: нээлттэй (public) горимд ажиллаж буй, хэн ч бүртгүүлж болдог суулгац операторуудаа хаанаас нэвтрэхийг нь зааж, өөрчлөх бүрд secret + deploy + nginx reload шаарддаг байв.

Одоо шийдвэрийг платформ өөрөө, хүсэлт бүр дээр гаргана:

хаалттай (private) → хаяг жагсаалтад байх ёстой (жагсаалт өгсөн бол)
нээлттэй (public)  → хаягаар огт хязгаарлахгүй

Үнэ нь ил: нээлттэй суулгац дээр танихгүй хүн консолын нэвтрэх дэлгэц хүртэл хүрнэ. Ард нь хэвээрээ — хостоос гараар үүсгэсэн оператор бүртгэл, нууц үг, баталгаажсан хоёр дахь хүчин зүйл, өөрчлөлт бүрд дахин асуух step-up. Хаягийн жагсаалт бол таван давхаргын хамгийн гадна талынх бөгөөд платформын өөрийнх нь «энэ суулгац хэнийх вэ» гэсэн хариулттай зөрчилддөг цорын ганц нь байлаа.

nginx-ийн snippet хэвээр — хүсвэл ирмэг дээр давхар хаалт болно. CONTROL_PLANE_ALLOWED_CIDRSopen гэж бичвэл түүнийг өргөнө. 0.0.0.0/0 гэж бичих боломжгүй хэвээр: буруу бичсэн prefix консолыг чимээгүй нээхээс сэргийлнэ, харин open гэдэг үгийг санамсаргүй бичихгүй.

Changed — open-gerege-core-оос бүрмөсөн салав

Гурван багц үлдсэнийг nexus дотроо бичив:

Хуучин Шинэ Юу
pkg/eid internal/kernel/eidrp eID-гийн RP клиент (QR/push нэвтрэлт, poll, төлөөлөл)
pkg/gemini internal/kernel/gemini generateContent + WAV туслах
core/business/usecases/sign internal/kernel/eidsign PDF/digest-д PIN2 гарын үсэг, PAdES

go.mod-оос github.com/gerege-systems/open-gerege-core бүрэн хасагдав. pdfcpu, digitorus/pdf, digitorus/pdfsign гурав indirect байснаа шууд хамаарал болов — өөрчлөлт нь тэднийг ХЭН эзэмшиж байгаад л, аль хувилбар ажиллаж байгаад биш.

Юуг нь авчирсангүй: gemini-гийн embedding клиент, streaming, model fallback — энэ репод хэн ч дуудаагүй. eID-гийн гарын үсэг зурагчид, PKI самбар, төлөөллийн бичилт — өөр бүтээгдэхүүнийх.

Wire protocol гурвуулаа өөрчлөгдөөгүй. Гарын үсгийн багц дээр нэг зүйл сайжрав: өмнө нь алдааны утгыг («represent» гэсэн үг агуулж буй эсэх) хайж HTTP статус шийддэг байсныг одоо errors.Is-ээр яг таарууллаа — үг солиход статус өөрчлөгддөггүй болов.

Гарын үсгийн урсгалын хамгаалалтууд хэвээр бөгөөд одоо тестээр барьцаалагдав: өөр иргэний session «олдсонгүй» гэж хариулна (IDOR), хэрэглэгчийн өгсөн зургийн URL дотоод сүлжээ рүү хүрэхгүй (dial түвшний SSRF шалгалт, redirect дагахгүй), production-д түр зуурын Document-Signer хориотой, displayText кирилл тэмдэгтээр таслагдана.

Changed — eID-ийн RP клиентийг nexus дотроо бичив, person блокийн шинэ нэршилд оруулав

eID нь person блокоо core.gerege.mn-ий нэршилд оруулж, birthDate, gender, familyName, geID нэмсэн. Гурван нэр нь гурван ӨӨР ойлголт бөгөөд одоо тэрийгээ хэлдэг болов:

firstName  = нэр            (өөрийн нэр)
lastName   = ЭЦГИЙН нэр     (сертификатын subject-д ордог)
familyName = УРГИЙН овог    (сертификатад ОРОХГҮЙ)

Өмнө нь givenName/surname хоёрыг уншиж байсан бөгөөд «surname» гэдгийг эцгийн нэр рүү тулгадаг байсан нь англиар зөв уншигдаж, монголоор ил буруу байдаг төрлийн алдаа.

open-gerege-core/pkg/eid-ийг ашиглахаа больж, RP клиентээ internal/kernel/eidrp-д бичив. Хоёр шалтгаан, хоёр дахь нь чухал:

  • Тэр сан өөр репогоос, өөрийн хуваарьаар гардаг. eID geID нэмэхэд дугаар нь энэ платформын доод давхаргад тасарч, хийх зүйл нь хувилбар гартал хүлээх л байв. Нэвтрэлт гэдэг version bump-ын цаана байх ёсгүй.
  • Тэр сангийн гадаргуугаас нexus ердөө ДӨРВИЙГ л ашигладаг байсан (хоёр initiate, poll, төлөөлөл). Үлдсэн нь — гарын үсэг зурагчид, PKI самбар, төлөөллийн бичилт — өөр бүтээгдэхүүний хэсэг, дагаж хөрвөгдөж явдаг байлаа.

Wire protocol өөрчлөгдөөгүй: ижил зам, ижил ACSP_V2 body, ижил Bearer. Хуучин givenName/surname талбарыг fallback болгож уншина — нэг тал шинэчлэгдээд нөгөө нь хоцорсон өдөр иргэн бүрийн нэр алга болохоос сэргийлнэ.

Changed — eID-ээр нэвтэрсэн иргэний хаяг утгатай болов, geID нь доош дамжина

eID-гийн session хариу COMPLETE+OK үед person.geID — иргэний core.gerege.mn дахь дугаар — буцаадаг болсон. Түүнээс өмнө энэ платформ eID-ээр анх нэвтэрсэн иргэнд eid+3854e516490de4f6184ac3af2d8cc8b8@identity.invalid гэсэн зохиомол хаяг үүсгэдэг байв: давхцахгүй, тогтвортой, гэхдээ хүн харахад утгагүй — тэр дундаа энд холбогдсон RP-үүд түүнийг жинхэнэ хүний email claim болгож хүлээж авдаг байсан.

Одоо geID мэдэгдэж байвал хаяг нь 10000263@gemail.com. Хуучин хаягтай бүртгэлүүд дараагийн нэвтрэлтдээ шинэчлэгдэнэ — тэр хаягаар хэн ч нэвтэрч байгаагүй тул алдах зүйл алга. Өөрийн жинхэнэ хаягаар бүртгүүлээд дараа нь eID-ээ холбосон хүний хаягийг хөндөхгүй: нэвтрэх арга нэмэх нь нэр солих шалтгаан биш.

geID нь platform.users.ge_id баганад тусад нь хадгалагдана (NULL зөвшөөрнө — eID-д заавал байх талбар биш) бөгөөд нэг дугаар = нэг хүн гэдгийг unique index барина. Доод урсгал руу хоёуланг нь өгнө: email ба ge_id нь id_token, userinfo хоёуланд гарч, discovery-гийн claims_supported-д зарлагдав. Хаягнаас нь дугаарыг нь салгаж авдаг RP байх ёсгүй — тэр нь хаягийн хэлбэр өөрчлөгдөх өдрийг хүртэл ажилладаг код юм.

Тайлбар: open-gerege-core/pkg/eid-ийн RP клиент нь person блокоос geID-г задлахгүй тул тэр замаар ирэхгүй байна. Түүнийг зөв уншдаг болтол дугаарыг Gerege Core бүртгэлээс регистрээр нь авна — geID гэдэг нь тодорхойлолтоороо core.gerege.mn-ий users.id тул үр дүн нь ижил. Токен байхгүй, бүртгэл хүрэхгүй, иргэн бүртгэлд байхгүй — гурвуулаа адилхан «geID алга» гэж дуусах ба аль нь ч eID зөвшөөрсөн нэвтрэлтийг унагаах шалтгаан биш.

Fixed — Консолын нүүр хуудас цагаан дэлгэц болдог байсныг зассан

cp.<домэйн>/cp нь «This page couldn't load» гэж унадаг байв. Шалтгаан нь нэг мөрөнд: observability.New(...) нь Warnings callback-ыг огт дамжуулаагүй тул warnings талбар JSON-д null болж, нүүр хуудас түүнийг .map хийхэд бүх дэлгэц уначихдаг байлаа. Go-д nil slice нь null болдог бөгөөд жагсаалт гэж зарласан талбарт null ирэх нь «богино жагсаалт» биш, өөр төрөл.

Хоёуланг нь зассан: callback-ыг холбож (нүүр хуудас платформын өөрийн анхааруулгуудыг дахин харуулна), мөн Overview буцахдаа бүх жагсаалтаа хоосон жагсаалт болгодог болов — найман талбарыг найман газарт санахын оронд нэг дор.

Added — Тохиргоог консолоос: нууц бус утга нэг дэлгэцэд, түлхүүрүүд нөгөөд

.env засаад контейнер дахин асаах гэдэг нь тохиргоо өөрчлөх ганц зам байв. Одоо /cp/config дээр:

  • Тохиргооai.tts_model, brand.name, observability.prometheus_url, observability.alertmanager_url, observability.grafana_url нэмэгдэв. Хуучин таван түлхүүрийн адил: утга хаанаас ирснийг харуулна, өөрчлөлт бүр шалтгаантай, түүхтэй, буцаагдана. Env хувьсагч нь fallback хэвээр.
  • Түлхүүрүүдai.gemini_api_key, core.api_token, reports.smtp_url.

Түлхүүрүүд нь тохиргооны хүснэгтэд ОРООГҮЙ бөгөөд орох ч ёсгүй: internal/kernel/settings нь нууц Kind байхгүй, нууц мэт нэрийг panic-аар татгалздаг гэдгээрээ тэр хилээ кодоор барьдаг. Оронд нь тусдаа хүснэгт, тусдаа дүрэмтэйгээр:

  • утга нь баганад хүрэхээсээ өмнө AES-256-GCM-ээр битүүмжлэгдэнэ (INTEGRATION_ENCRYPTION_KEY — холбогчийн эрхийг битүүмжилдэг яг тэр түлхүүр, тиймээс шифр нь хоёр plane-ийн доор kernel/security-д нүүв);
  • буцааж уншигдахгүй — утга буцаадаг route байхгүй, дэлгэц зөвхөн эх сурвалж, сүүлийн дөрвөн тэмдэгтийг харуулна;
  • бичих бүрд хоёр дахь хүчин зүйл дахин шаардана;
  • audit мөр нь юу өөрчлөгдсөнийг бичнэ, утгыг нь БИШ — хуучин утгыг агуулсан түүх гэдэг бол «энэ суулгацын хуучин түлхүүрүүдийн жагсаалт» гэсэн үг.

Хэрэглэгч талдаа: copilot болон дуут боломжийн клиент нь түлхүүр солигдвол дахин баригддаг болов (өмнө нь зөвхөн загвар солигдоход), тайлангийн SMTP relay нь илгээх үедээ уншигддаг болов — өмнө нь ачаалах үед л шийддэг байсан тул консолоос тавьсан утга дахин ачаалтал үйлчлэхгүй байх байлаа.

Added — Байгууллагын мэдээллийг регистрээс нь шинэчлэх

/organisation дээр «Core-оос шинэчлэх» товч. Регистрийн дугаараар Gerege Core-оос албан ёсны нэр, хаяг, утас, и-мэйлийг татна. Регистрд байхгүй талбарыг хөндөхгүй — байхгүй утгаар дарж хоослох нь огт ажиллаагүйгээс дор.

Товч болохоос товлосон ажил биш: буцаж ирсэн утга нь админы нүдний өмнө байгаа талбаруудыг дарж бичдэг тул хүнгүйгээр хийвэл Мягмар гаригт хийсэн засвар Лхагва гаригт чимээгүй алга болно.

Added — Анхны тохиргооны шидтэн: байгууллагаа регистрээр нь татаж авдаг

Тушаалын мөр бүх хүнд тохирохгүй. Шинэ deployment дээр одоо /setup гэсэн гурван алхамт дэлгэц гарна: байгууллага, админ, нууц үг. Байгууллагын болон хүний регистрийн дугаараар Gerege Core-оос нэр, албан ёсны нэр, и-мэйлийг татна — суулгасан хүний гараар бичсэн нэр нэхэмжлэх дээрхтэй таарахгүй байдгийн эсрэг.

Вэб дээрх first-run хуудсыг аюулгүй болгодог хэсэг нь ихэвчлэн орхигддог: нээлттэй /setup бол хамгийн түрүүнд хүрсэн хүнийх. Тиймээс энэ нь токеноор зэвсэглэдэг — 256 бит, зөвхөн байгууллагагүй үед, зөвхөн санах ойд, ачаалах үед лог руу нэг удаа бичигдэж, байгууллага үүссэн даруйд устана. Токенгүй үед эндпойнтууд 401 биш 404 буцаана: "буруу токен" гэж хэлэх нь таах токен байгааг хэлж байгаа хэрэг.

tenant-bootstrap команд хэвээрээ — терминалтай хүнд, хөтөчгүй сервер дээр.

Added — Шинэ deployment-д эхний байгууллагаа нээх команд

Шинэ deployment дээр орох зам байсангүй: бүртгүүлэх дэлгэц байхгүй, demo бүртгэл production дээр үүсэхгүй, консол нь өөрийн vhost, оператор бүртгэл, TOTP шаарддаг. Өмнөх хоёр бүтээгдэхүүн гараар бичсэн SQL-ээр орсон бөгөөд тэр SQL нь илэрхий биш гурван зүйлийг зөв хийх шаардлагатай — admin эрхийг tenant мөрийн trigger үүсгэдэг, админ эрх platform.users.is_admin дээр биш гишүүнчлэлийн эрх дээр тогтдог, гишүүнчлэлд эрхийн мөр нь тусад нь хэрэгтэй — бөгөөд эхний оролдлогод гурвуулаа буруу хийгдсэн.

Одоо tenant-bootstrap команд образ дотор явна, консолын operator-bootstrap- тай яг ижил зарчмаар: DATABASE_URL эзэмшдэг хүн ажиллуулна, нууц үгийг TTY-ээс асууна, ард нь юу ч үлдээхгүй. Байгууллага аль хэдийн байвал татгалзана — анхдагч нууц үг ч биш, үүрд үлддэг env хувьсагч ч биш, ачаалахаас хойш онгорхой зогсох first-run вэб хуудас ч биш.

Асах үед байгууллагагүй deployment-ийг лог нь хэлж, яг ажиллуулах командыг зааж өгдөг болов — өмнө нь энэ төлөв чимээгүй байсан тул хоёр удаа "нууц үг буруу" гэсэн шинжээр дебаг хийгдсэн.

Fixed — Суулгаагүй аппын цэс хажуугийн самбарт үлддэг байсныг зассан

«AI тохиргоо» ба «Интеграцууд» хоёрыг бүрхүүл өөрөө бичдэг байв. Тэр хоёр өнөөдөр апп болсон тул тэдгээрийг суулгаагүй байгууллага ч гэсэн цэсэндээ харсаар, дарахад л 404 авдаг байв.

Одоо модуль өөрсдөө зарлана (Group: settings), тэгэхээр:

  • апп суулгаагүй бол цэс байхгүй — цэсний API суулгасан аппын бичлэгийг л буцаадаг;
  • эрхгүй хүнд ч харагдахгүй: хоёулаа MenuPermission нь бичих эрх (ai.manage, integrations.manage) — prompt бичих нь байгууллагын бүх гишүүний ярьдаг туслахыг тодорхойлно, холбогчийн хаяг нь сервэрийг хэн нэгний бичсэн хаяг руу хүсэлт явуулдаг болгоно.

Бүрхүүл мөн бүх chrome аппын цэсийг зурдаг болов, эхнийхийг нь биш. Гурав нь тийм тугтай (байгууллага, туслах, холбогч) бөгөөд өмнө нь эхнийх нь л зурагддаг байсан тул нөгөө хоёрын дэлгэц утсан дээр хаанаас ч хүрэхгүй байв — доод таб мөр зөвхөн rail аппуудыг жагсаадаг.

Fixed — Суулгасан ч бинарь үүрдэггүй апп чимээгүй алга болдог байсныг хэлдэг болов

Апп өөр репод нүүхэд суулгацын мөр нь үлддэг. Тэр байгууллагын хувьд апп нь маршрут нь 404, цэсний API-д байхгүй — өөрөөр хэлбэл хаанаас ч харагдахгүй болно. Утсан дээр аппуудын хооронд явах цорын ганц зам нь доод таб мөр тул энэ нь «цэс эвдэрлээ» гэж уншигдана.

Суулгац буруу байгаа хэрэг биш — аппын явалтыг давж шинэчилсэн оператор тэр байдалд хууль ёсоор орно — харин юу ч дуугардаггүй байсан нь алдаа. Одоо асалт бүрт нэрсийг нь бичнэ:

catalog: organisations have apps installed that this binary does not carry;
their routes answer 404 and they appear in no sidebar.
Either deploy a distribution that compiles them, or uninstall them
apps=[io.gerege.nexus.documents io.gerege.nexus.egov io.gerege.nexus.organisation]

Гадаад апп үүнд ороогүй: тэдгээр нь өөр газар ажилладаг бөгөөд энэ бинарь тэднийг үүрэх ёсгүй.

Removed — Цэсний blueprint пакет устав; модуль өөрийн цэсээ бүрэн зарлана

internal/platform/menu/blueprints.go нь аппын id-гаар түлхүүрлэгдсэн хүснэгт байв: «энэ апп цаашид ийм дэлгэцүүдтэй болно» гэсэн жагсаалт, зөвхөн энэ репогийн аппууд өөрсдийгөө нэмж чадах. Тэр хүснэгт устлаа.

nexus.MenuDefinition.Group нэмэгдэв (MenuGroupModules, MenuGroupSettings). Аппын хоёр толгойн алинд нь дэлгэц харьяалагдахыг модуль хэлдэг болов — эцэг нь платформынх хэвээр, тэгэхгүй бол апп өөр аппын бүлэг рүү бичлэг оруулж чадна. Талбар нэмэх нь эвдэх өөрчлөлт биш (RELEASING §1).

sso_clients нь blueprint-ийн таван бичлэгээ өөрөө зарлав, замууд нь өөрчлөгдөөгүй (/module/sso-clients/<id>).

Хоёр шалгуур модулиудынхаа хажууд нүүлээ: цэсний бичлэг бүр жинхэнэ хуудастай байх, шошго бүр долоон хэлээ хамрах. Аль аль нь одоо nexus.List()-ийн оронд internal/apps-ийн модулийн хүснэгтээс асууна.

Removed — Тайлангийн апп цөмөөс гарав

internal/apps/reports ба domain/reports нь client-gerege-nexus руу нүүлээ. Гарах боломжтой болсон нь v1.12.0-ийн дөрвөн гэрээ (ReportEngine-ийн Form/RunConsolidated, ReportSchedules, ReportGrants) ба v1.13.0-ийн InstalledApps.

Хөдөлгүүр цөмд үлдэв — тайлангийн SQL ажиллуулах, экспорт бичих, гурван цагт шуудан илгээх, өөр байгууллагын мөрийг зөвшөөрлөөр унших. report_schedules ба report_grants хүснэгтүүд ч мөн адил: платформ өөрөө бичдэг тул аппын хамт явсангүй — Өртөөгийнхөөс ялгаатай тал нь энэ.

Тэгэхээр суулгац тайлангаа шуудангаар авсаар байна, дэлгэц нь байхгүй ч гэсэн. Хуваарь платформын мөр, sweep платформын давталт.

/api/v1/reports/* арван хоёр маршрут энэ бинариас алга. Хүснэгт устгасан миграц байхгүй.

Энэ бол цөмөөс гарсан зургаа дахь апп бөгөөд internal/apps дотор суулгацын өөрийн бүтээгдэхүүн үлдсэнгүй: одоо тэнд байгаа дөрөв — туслах, холбогч, ээлж солих PIN, SSO клиентийн бүртгэл — бүгд платформын рельсийн дэлгэц.


[1.13.0] - 2026-08-23

Added — nexus.InstalledApps: аль апп суусныг distribution ч асууж чадна

Тэр чадамж нь internal/apps.InstalledApps нэрээр нийтлэгддэг байв — repo-гийн гаднах модуль тэр төрлийг нэрлэж чадахгүй тул асууж ч чадахгүй. Бүртгэл нь төрлөөр түлхүүрлэдэг гэдгийг санавал энэ хоёр өөр түлхүүр байсан гэсэн үг.

Гэрээ экспортлогдож, nexus.AppsOf(ctx, tenantID) туслах нэмэгдэв. Энэ бол PDF-ийн рельс өнөөдөр давсан яг тэр алдаа, яг адилаар илэрсэн: цөмөөс гарсан апп платформын өгч байгаа чадамжийг авч чадахгүй.


[1.12.0] - 2026-08-23

Note — ReportEngine таван метод авсан нь v1 доторх шийдвэр

RELEASING.md §1 нь экспортолсон interface-д метод нэмэхийг эвдэх өөрчлөлт гэж нэрлэдэг: гаднаас түүнийг хэрэгжүүлж байсан бүх төрөл компиллогдохоо болино. Энэ хувилбар ReportEngine-д таван метод нэмж байгаа тул журмаараа major байх ёстой.

Minor-оор гаргаж байгаа шалтгаан, ил тод: ReportEngine бол платформ өгдөг чадвар — distribution нь дуудагч тал, хэрэгжүүлэгч нь энэ репогийн адаптер. Экосистемд өөр хэрэгжүүлэгч мэдэгдэхгүй байна. Хэрэв танай distribution nexus.ReportEngine-ийг өөрөө хэрэгжүүлдэг бол энэ хувилбар дээр компайл унана — тэр тохиолдолд бидэнд хэлээрэй, дараагийн major дээр гэрээг хуваана.

Added — Тайлангийн гэрээ бүрдэв: ReportSchedules, ReportGrants, Form, RunConsolidated

pkg/nexus/reportengine.go гурван цоорхойгоо нэрлээд «эдгээр буутал reports апп internal/platform/reporting-ийн импортоо хадгалж, энэ репод үлдэнэ» гэж бичсэн байсан. Гурвуулаа буув.

  • nexus.ReportSchedules, nexus.ReportGrants — хуваарь ба хуваалцах гэрээний бичлэгүүд. Engine дээр наагаагүй, тусдаа хоёр гэрээ: тэдгээрийг гурван цагт шуудангаар илгээдэг нь платформын sweep, өөр байгууллагын мөр уншихыг зөвшөөрдөг нь платформын consolidated run.
  • ReportEngine.Form — параметрийн маягт, dropdown нь дүүргэгдсэн. Dropdown нь тайлангийн зарласан SQL тул түүнийг ажиллуулах нь engine-ийнх; апп үүний тулд өөрийн өгөгдлийн сангийн бариул барьдаг байв.
  • ReportEngine.RunConsolidatedRun-аас тусдаа: нэг нь аюулгүй, нөгөө нь өөр байгууллагын мөрийг уншина, хоёуланг нэг дуудлага болгох нь тэр ялгааг арилгана.
  • ReportEngine.ValidateCron, NormalizeFormat — дэлгэц талбар бөглөх бүрд шалгадаг тул бүтэн хуваарь зохиох шаардлагагүй.

nexus.ErrReportScheduleNotFound, ErrReportGrantExists, ErrReportGrantNotPending, ErrReportGrantNotFound, ErrOrganisationNotFound нэмэгдэв — апп бүрийг өөр хариулт болгодог тул sentinel.

Changed — Тайлангийн sweep платформынх болов

Гурван цагт тайлан илгээдэг давталтыг reports апп эхлүүлдэг байсан нь дэлгэцийг суулгацын аж ахуйн ажилд хариуцлагатай болгож байв: аппыг устгасан байгууллага хуваариа чимээгүй хүлээж авахаа болино. Одоо платформ эхлүүлнэ.

Мөн internal/apps/reports нь internal/platform-ийн нэг ч импортгүй болов — domain/reports/postgres устаж, түүний бичилтүүд платформын адаптерт орлоо.

Removed — Өртөөгийн даалгаврын самбар цөмөөс гарав

internal/apps/urtuu ба domain/urtuu нь client-gerege-nexus руу нүүлээ. Гарах боломжтой болсон нь v1.11.0-ийн nexus.PeerDirectory: апп сувгийн хүснэгтийг арван дөрвөн газар шууд уншдаг байсныг гэрээ болгосон (ADR 0004-ийн Саад 2).

Суваг цөмд үлдэв — холбоос, гарын үсэг, дугтуйны дараалал, дахин оролдлого, кодын sync ба тэдгээрийн таван хүснэгт. Суулгац Өртөөний сүлжээнд байхын тулд даалгаврын самбар суулгасан байх шаардлагагүй: дугтуй ирж, шалгагдаж, хадгалагдаж, хүлээн авсан гэж хариулагдаад, тэр төрлийг уншдаг модуль компайллагдах өдрийг хүлээнэ — модулийн анхны тайлбар үүнийг бичсэн байсан.

  • /api/v1/urtuu/tasks/* арван маршрут энэ бинариас алга. /api/v1/urtuu/{peers,codes,exchange} ба /.well-known/urtuu.json хэвээр.
  • Миграц 00078 нь urtuu_tasks, urtuu_task_events, urtuu_numbers гурвыг устгана. Өгөгдөл устана; схем нь аппын хамт явж modules/urtuu/migrations/00001_urtuu.sql болов. Энэ хостын таван суулгац дээр гурвуулаа хоосон байсныг устгахын өмнө тоолж шалгасан.
  • Demo seed urtuu биш ai суулгана — явсан аппын slug юуг ч нэрлэхгүй.

[1.11.0] - 2026-08-23

Changed — Туслах апп болов

Copilot, ярианаас бичвэр, бичвэрээс яриа, орчуулга, prompt ба мэдлэгийн удирдлага — арван маршрут server.go-гоос гарч internal/apps/ai болов. Өмнө нь суулгац бүр туслахыг үүрдэг, аль нь ч устгаж чаддаггүй байсан; тэр бол энэ архитектурт апп гэдгийн тодорхойлолт (CORE_BOUNDARY_PLAN §4.2).

Суулгацад:

  • Маршрутын хаяг өөрчлөгдөөгүй (/api/v1/ai/*, /api/v1/admin/ai/*), харин одоо аппын хаалганы ард: тухайн аппыг суулгаагүй байгууллага 404 авна. Каталогт io.gerege.nexus.ai нэрээр байгаа тул дэлгүүрээс суулгана.
  • Эрх нь хоёр: ai.read (асуух, уншуулах, орчуулах) ба ai.manage (prompt ба мэдлэг бичих). Өмнө нь эхнийх нь эрхгүй, хоёрдугаарх нь requireAdmin байв — одоо ai.manage-ыг админаас өөр рольд ч өгч болно.
  • Аппын самбарт хавтан гарахгүй: манифест нь chrome бөгөөд туслах нь бүрхүүлийн чат хэсгээс нээгддэг, өөрийн дэлгэцгүй.

Added — nexus.PeerDirectory: Өртөөгийн сувгийг уншдаг гэрээ

Даалгаврын самбар нь сувгийн хүснэгтүүдийг арван дөрвөн газар шууд уншдаг байв: urtuu_peers-ыг ес удаа JOIN хийж (даалгавар, үйл явдал, дугтуй, гурван тайлан), urtuu_request_codes, urtuu_peer_codes, urtuu_deliveries-ыг тус бүр. Хажууд нь «энэ хоёр пакет бол давхаргаар хуваагдсан нэг бүтээгдэхүүн, нэг схем хуваалцдаг» гэсэн тайлбар бичээстэй байсан — тэр нь хоёул нэг репод байх хүртэл үнэн бөгөөд ADR 0004 энэ аппыг цөмөөс гаргах гол саад гэж нэрлэсэн.

Одоо дөрвөн метод:

Peers(ctx, tenantID) ([]Peer, error)                        // хэн нөгөө талд байна
RequestCode(ctx, tenantID, code) (RequestCode, bool, error) // код юу гэсэн үг
CodeOpenOn(ctx, tenantID, peerID, code) (bool, error)       // тэр холбоос дээр зарлагдсан уу
DeliveryLoad(ctx, tenantID, from, to) ([]PeerLoad, error)   // тухайн хугацаанд юу явсан

nexus.Directory-гийн хэв маягаар: хуудсанд нэг уншилт, ID-г нэр болгох нь санах ойд. Таван зуун даалгаврын самбар нь давтагддаг хариултын төлөө таван зуун JOIN байсан.

Тайлангуудын бүлэглэлт нэрээр биш ID-гаар болов: нэг нэртэй хоёр холбоос хоёр мөр хэвээр үлдэнэ, өмнө нь нийлдэг байсан.

Changed — Ээлж солих PIN апп болов, нэвтрэлт нь цөмд үлдэв

Кассын дэлгэц дээр ажилтан солигдоход бичдэг богино нууц үг — хүснэгт, түгжигдэлт, хэлбэрийн дүрэм, PIN тавих дэлгэц — internal/apps/staffpin болов. Дэлгүүргүй суулгац эдгээрийг үүрэх шалтгаангүй.

Нэвтрэлт өөрөө гараагүй, гарч ч болохгүй. POST /api/v1/devices/staff/pin нь платформын session нээдэг бөгөөд session нээх нь аппын эрх биш — SDK зориудаар ийм зам санал болгодоггүй. Тэр маршрут одоо nexus.StaffCredential-ыг хэрэгжүүлсэн аппаас «энэ хэн бэ» гэж асууна.

  • Ийм апп байхгүй суулгац 404 хариулна («энэ суулгац төхөөрөмж дээр хэнийг ч танихгүй»), PIN буруу гэж хэлэхгүй.
  • Аппыг суулгаагүй байгууллага дээр PIN ажиллахгүй, буруу PIN-ээс ялгагдахгүй хариу өгнө. Энэ шалгалт нь аппын дотор: тэр маршрут төхөөрөмжийн токентой, session-гүй ирдэг тул аппын хаалга урд нь зогсож чадахгүй.
  • Уншиж чадаагүй тохиолдол нь татгалзал биш: 503, 401 биш. Өгөгдлийн сангийн доголдлыг «PIN буруу» гэж хэлбэл хүн зөв PIN-ээ дахин дахин бичнэ.
  • PUT /api/v1/admin/devices/staff-pin нь аппынх болов, staff_pin.manage (AdminOnly) эрхтэй.

Added — nexus.StaffCredential

Хуваалцсан төхөөрөмж дээрх богино нууц үгийн гэрээ. Нууц үг нь юу байх — PIN, тэмдэг, карт — бүтээгдэхүүний шийдвэр; session нээхийг зөвхөн платформ хийнэ гэдэг нь тийм биш. ErrStaffCredentialRejected нь буруу нууц үг, түгжигдсэн бүртгэл, аппгүй байгууллага гурвуулангийн хариулт — гурвыг нь ялгаж чаддаг дуудагч кассын өмнө зогсох хэн бүхэнд гурвыг нь хэлж чадна.

Changed — Холбогчийн удирдлага апп болов

Zoom, Teams, Google, Dropbox холболт бүртгэх дэлгэц, OAuth-ийн урсгал, илгээлтийн бүртгэл — server.go-гийн ес маршрут internal/apps/integrations болов. Хурлын өрөө захиалдаггүй, гадагш юу ч файлддаггүй суулгац тэднийг үүрэх шалтгаангүй.

  • Хаяг өөрчлөгдөөгүй (/api/v1/integrations/*), эрх нь integrations.manage бөгөөд AdminOnly — өмнөх requireAdmin-тэй ижил утгатай, гэхдээ одоо тенантын админ өөр рольд гараар өгч болно.
  • Суулгаагүй байгууллага 404 авна; каталогт io.gerege.nexus.integrations.
  • OAuth-ийн буцах хаяг хаалганаас гадуур: провайдер хэрэглэгчийн хөтчийг манай session-гүйгээр буцаадаг тул тэнд аппын хаалга тавих нь холболт бүрийг сүүлийн алхам дээр нь унагаана.

Рельс нь цөмд үлдэв — internal/platform/integration: менежер, провайдерын бүртгэл, OAuth солилцоо, нууцлалын түлхүүр, илгээх давталт. Хоёр зүйл түүнээс хамаарна: PDF гарын үсгийн рельс баримтаа түүгээр илгээдэг, nexus.MeetingBooker нь түүний адаптер.

Changed — httpx.DecodeLimited

Хүсэлтийн биеийг хэмжээтэйгээр уншдаг туслах нь ai_handlers.go дотор амьдардаг байсан бөгөөд платформын есөн handler түүнийг хэрэглэдэг байв. Гурав дахь хуулбар үүсэхийн өмнө httpx рүү гарлаа — энэ пакет нь яг ийм хүсэлтийн үгсийн санд зориулагдсан.

Added — nexus.QuotaGate ба nexus.Quota

Сарын квотыг модуль нэрээр нь гуйдаг болов:

rr.Use(nexus.QuotaGate("ai"))

Хязгаарыг control plane зардаг тул модуль өөрөө тоог нь мэдэх ёсгүй. Квот хэмждэггүй суулгац, эсвэл хэмждэггүй төрөл дээр бүх хүсэлтийг нэвтрүүлдэг middleware буцна — RateLimit-ийн адил, дутуу тоолуур маршрутыг унагаах ёсгүй.

Fixed — nexus.RateLimit анх удаа үнэхээр хуваалцсан төсөв болов

SDK нь модульд «суулгац даяар хуваалцсан төсөвтэй хязгаарлагч» амлаж байсан бөгөөд өнөөдрийг хүртэл нэг репликийн IP хувин буцаадаг байв: нэг gateway-ийн ард гурван реплик гурван өөр төсөв барьж, суулгац нийтдээ юу ч барихгүй. Платформын өөрийн үнэтэй маршрутууд — нэвтрэлт, poll, туслах — Redis тоолуурыг server.goгараар хажууд нь холбож байсан, яг тэр нь модулийн хийж чадахгүй зүйл. Одоо чадамж нь хоёуланг нь өгнө; Redis-гүй суулгац хуучин локал хувиндаа үлдэнэ.


[1.10.1] - 2026-08-23

Fixed — Модулийн схем суулгацын дараа ирвэл хэзээ ч ажиллахгүй байв

Модулийн өөрийн миграц нь ганцхан газраас — суулгах замаас — дуудагддаг байсан. Тиймээс аль хэдийн суусан аппын схем нь дараа нь модуль руу нүүвэл тэр миграц хэзээ ч ажиллахгүй: app_installations дээр «installed», маршрут нь mount хийгдсэн, харин хүсэлт бүр relation ... does not exist гэж хариулна.

Онолын биш: v1.10.0-д гурван апп энэ репогоос гарч схемээ авч явахад 00077 нь тэдний үлдээсэн хүснэгтийг устгасан, тэдгээрийг өмнө нь суулгасан суулгац дээр модулийн миграц ажиллах зам байгаагүй. Стек эрүүл асаж, бүрхүүл зурагдаж, нэвтрэлт ажиллана — зөвхөн тэр аппын дэлгэц л 500 өгнө.

Одоо applyCatalogToInstallations бүрт (асалт дээр нэг удаа, дараа нь каталогийн синк бүрт) бинарь дотор компайллагдсан модуль бүрийн схем ажиллана. Суулгацаар биш модулиар: модулийн хүснэгт бол тенантынх биш суулгацынх — runModuleMigrations анхнаасаа гүйлгээнээс гадуур байгаа шалтгаан яг тэр. Хэрэглэгдэхгүй схем бол ямар ч суулгац өөрийн үүрдэггүй апп бүрийн хувьд байдаг төлөв.

goose-ийн ачаар идемпотент; нэг модулийн алдаа нөгөөг нь нуухгүй; өгөгдлийн сан хараахан босоогүй байхад асалтыг зогсоохгүй — дараагийн синк дээр дахин оролдоно.


[1.10.0] - 2026-08-23

Removed — Цахим засаг, баримт бичиг, байгууллага цөмөөс гарав

Гурван апп — io.gerege.nexus.egov, io.gerege.nexus.documents, io.gerege.nexus.organisation — нэг өдөрт client-gerege-nexus руу нүүлээ. Цөмд үлдсэн нь тэдний ашигладаг рельсүүд: internal/platform/esign (PDF гарын үсэг), gerege (ХУР-ын клиент), eid, dan, directory. Гарах боломжтой болсон нь өмнөх PR-ууд: nexus.StateRegistry/AuditReader (#184), SigningRails/EIDSigner/DANAuthenticator (#185), ReportEngine (#186), Directory (#187).

Схем нь аппуудтайгаа хамт явав — nexus.Migrations (Үе 3) — ба 00077 нь цөмөөс арван нэгэн хүснэгтийг устгав: document_* ес, departments, organisation_people.

Суулгацад юу өөрчлөгдөх вэ:

  • /api/v1/core/departments ба /api/v1/core/people-ийн redirect устав. Тэдний очих газар энэ бинарид байхгүй болсон, харин redirect нь 308 — метод ба биеийг хадгалдаг тул PUT нь Location-ыг дагаад 404 руу орж бичилтээ алддаг, дээрээс нь үхсэн хаягийг үүрд кэшлэ гэж хэлдэг байв. Аппыг үүрсэн distribution хосыг нь өөрийн маршрутын хажууд дахин бүртгэж болно. /api/v1/core/organisation ба /me/preferences нь платформын маршрут руу заасаар байна.
  • Анхдагч апп нь distribution-ийн шийдвэр боловplatform.Options.DefaultApps. appinstaller.DefaultApps нь энэ репогийн аппуудыг нэрлэсэн литерал байсан бөгөөд сүүлчийнх нь гармагц утгагүй болсон: суулгац тенант бүрт өгөх ёстой апптай, түүнийгээ хэлэх аргагүй болно. Одоо хоосон, хоосон нь энгийн хариулт — өөрийн апп байхгүй платформ юу ч суулгахгүй. Хоосон байхад каталогийн хуучралтын шалгалт (verifyCatalogVersions) бас унтарна гэдгийг анзаараарай.
  • Demo seed-ийн аппууд documents/egov биш, reports/urtuu болов. Хуучин slug-ууд юуг ч нэрлэхээ больсон тул хоёр demo тенант хоосон sidebar-тай үлдэх байв.

Changed — Distribution-ийн модуль чадамжуудын дараа баригддаг болов

platform.Options.ExtraModules-ийн callback нь NewServer бүх чадамжаа Provide хийсний дараа дуудагдана — тэр дотор nexus.SigningRails, Link, Directory, ReportEngine, StateRegistry, AuditReader, EIDSigner, DANAuthenticator, Signer, RateLimiter, MeetingBooker. Distribution-ийн модуль конструктор дотроо тэдгээрийг гуйж чадна гэсэн үг.

PDF-ийн рельс одоо ганц түлхүүртэй — экспортолсон nexus.SigningRails. Хажууд нь *esign.Rails-ыг нийтэлж байсан нь internal/-ийн төрлөөр арын үйлчилгээний давталтыг түлхүүрлэдэг байсан: distribution нэрлэж ч, солиж ч чадахгүй, санамсаргүй устгасан Provide нь цэвэр эхэлж таван минутын дараа nil дээр panic хийдэг байв.

Added — Чадвар нэмэх нь SDK-гийн засвар байхаа болив

nexus.Provide[T](https://github.com/gerege-systems/open-gerege-nexus/blob/main/impl) ба nexus.Capability[T](). Чадвар нь өөрийнхөө төрлөөр түлхүүрлэгдсэн нэг бүртгэлд амьдардаг болсон тул distribution энэ репо сонсож ч байгаагүй чадвар нийтэлж чадна:

nexus.Provide[mydist.Pricing](https://github.com/gerege-systems/open-gerege-nexus/blob/main/myPricing{db})   // main()-д
p, err := nexus.Capability[mydist.Pricing]()   // модульд

nexus.Meetings() нэмэгдэв. MeetingBooker гэрээ 2026-08-15-нд зарлагдсан, адаптер нь тэр өдрөө бичигдсэн, авах арга нь хэзээ ч нэмэгдээгүй — зургаан хоног хэрэглэгдэх боломжгүй байсан.

nexus.ReportSink нь нэрлэгдсэн төрөл болов (өмнө нь нэргүй func(Report)). Чадвар төрлөөрөө хайгддаг тул нэргүй төрөл нь ижил гарын үсэгтэй бүхнийг нэг түлхүүр дээр буулгана.

internal/apps.Bootstrap нь есөн параметрийн оронд nexus.Platform нэгийг авдаг болов. Энэ нь internal/ тул экспортолсон гадаргууд өөрчлөлт биш, гэхдээ дээрх бүртгэл яагаад байгааг тайлбарладаг: тэр гарын үсэг 2026-08-09-өөс 08-20-ны хооронд 4-өөс 9 болж долоон удаа өөрчлөгдсөн.

Deprecated — Дөрвөн Use*

nexus.UseLink, nexus.UseDocumentFiler, nexus.UseAuditSink, nexus.UseReportSink нь Provide[T]-ийн нимгэн бүрхүүл болов. Зан төлөв яг хэвээр, Use*(nil) нь чадварыг хураах нь ч хэвээр.

Тус бүрийг nexus.Provide[<төрөл>](https://github.com/gerege-systems/open-gerege-nexus/blob/main/impl) -ээр солино:

Хуучин Шинэ
nexus.UseLink(l) nexus.Provide[nexus.Link](https://github.com/gerege-systems/open-gerege-nexus/blob/main/l)
nexus.UseDocumentFiler(f) nexus.Provide[nexus.DocumentFiler](https://github.com/gerege-systems/open-gerege-nexus/blob/main/f)
nexus.UseAuditSink(s) nexus.Provide[nexus.AuditSink](https://github.com/gerege-systems/open-gerege-nexus/blob/main/s)
nexus.UseReportSink(s) nexus.Provide[nexus.ReportSink](https://github.com/gerege-systems/open-gerege-nexus/blob/main/s)

Нэг major цикл үлдэнэ, дараагийн major дээр устана (docs/RELEASING.md §1). Ring(), Documents(), Audit(), RegisterReport() нь deprecated биш — дотроо Capability-ээр уншдаг болсон, гарын үсэг нь хэвээр.

Added — Баримт гарын үсэг зурагдах зүйлээ өөртөө авч явдаг болов

documents апп өнөөг хүртэл агуулгагүй байсан — гарчиг, төрөл, төлөв — тиймээс түүний «гарын үсэг» нь агуулгад холбогдоогүй зөвшөөрөл байв (ADR 0002). Одоо баримт файл авч явна, гарын үсэг нь түүнийг хамарна.

Гурван хэлбэр, ямар файл авч явж байгаагаас нь шалтгаална: PDF-д pades (гарын үсэг баримтын дотор ордог, энэ платформгүйгээр шалгагддаг), бусад файлд detached (SHA-256 дээрх баталгаажсан гарын үсэг), хавсралтгүй баримтад approval — 0002-ын тодорхойлсон зүйл, одоо гуравны нэг. Хэлбэрийг баримт шийднэ, дуудагч биш: сонголт өгвөл PDF-ээ сулаар зуруулах болно.

POST /documents/{id}/file (оруулах, documents.manage), GET /documents/{id}/file (татах, documents.read). Төрлийг байтууд шийднэ; зурагдсаны дараа файл хөлддөг; татахдаа digest шалгагдана.

Ёслол дуусахад юу зурагдсаныг шалгана: зам нь илгээснээс өөр digest баталгаажуулбал татгалзана. Бүртгэлд format ба covered_digest нэмэгдэв (миграц 00070, 00071).

ADR 0002-ын 3-р үе шат (eid.StartSignature устгах) цуцлагдав: хавсралтгүй баримтын зөвшөөрөл нь нэвтрэлтийн ёслол бөгөөд нэвтрэлтийн клиентийнх. Хоёр зам зэрэгцэн үлдэнэ.

Шийдвэр: docs/adr/0003-a-document-carries-what-is-signed.md.

Added — Гарын үсгийн зам нэг болж, SDK-д нийтлэгдэв

nexus.Signer — суурилуулалтын баталгаажсан гарын үсгийн зам, модуль харах хэлбэрээр. eidmongolia дээр хэрэгжсэн.

Шалтгаан нь энэ репод «гарын үсэг» гэдэг үг хоёр өөр зүйлийг нэрлэж байсан: internal/platform/esign нь агуулгын дээрх гарын үсэг үйлдвэрлэдэг бол internal/apps/documents нь иргэний баталгаажсан зөвшөөрлийг бүртгэж, signature_hash талбарт сессийн дугаар хадгалдаг байв. Хоёр дахь нь утгатай бичлэг ч баримтын агуулгад холбогдоогүй — зөвшөөрлийн дараа баримт өөрчлөгдвөл хадгалагдсан ямар ч утга түүнийг үгүйсгэхгүй.

Гэрээ нь нарийхан: digest дээрх гарын үсэг, төлөв, юу гарын үсэг зурагдсаныг шалгах. ДАН энэ гэрээний ард байхгүй — тэр нь зөвшөөрлийн зам бөгөөд апп нь аль нь болохыг хэлэх ёстой.

Баримтын бичлэг үнэн болов. documents апп нь баримтын агуулга хадгалдаггүй (мөрөнд гарчиг, төрөл, төлөв — PDF нь esign-ы өөр хүснэгтэд, холбоосгүй), тиймээс агуулгын гарын үсэг үйлдвэрлэж чадахгүй. API одоо proof талбар нийтэлж, бичлэг нь баталгаажсан зөвшөөрөл гэдгийг хэлнэ; signature_hash нэр хэвээр (клиентүүдийг эвдэхгүй) ч миграц 00069 нь баганад тайлбар бичив. Дэлгэц батламжийн сурвалжийг үргэлж харуулдаг болов — өмнө нь гэрчилгээ байвал түүнийг нуудаг байсан нь гэрчилгээг агуулгын гарын үсэг мэт уншуулж байв.

Ажиллахгүй суваг санал болгохоо болив. Гарын үсгийн цонх E-ID ба ДАН хоёрыг үргэлж санал болгодог байв — байгууллагын бодлого ч, суурилуулалт тухайн сувагтай эсэх ч дэлгэц дээр хүрдэггүй байсан. ДАН дээр тэр нь бүх deployment: амьд клиент байхгүй тул сонгоход л 503. Одоо GET /documents/{id}/signing-rails хоёр баримтыг хамтад нь хариулж, цонх ажиллахгүй сувгийг идэвхгүй болгож шалтгааныг нь хэлнэ — «байгууллага зөвшөөрөөгүй» (админ засна) ба «суурилуулалтад тохируулаагүй» (оператор засна) хоёр өөр асуудал, өөр хүнд.

Шийдвэр ба үе шатууд: docs/adr/0002-one-signing-rail.md.

Changed — Үлдсэн аппууд ч дүрмээ платформоос салгав

egov — ХУР клиент ба төрийн холболтууд одоо порт: лавлагаа, холболтын жагсаалт, асуултын түүх нь ХУР-гүйгээр, өгөгдлийн сангүйгээр ажиллана.

sso_clients — OAuth2 клиент бүртгэлийн шалгалтууд (фрагмент, wildcard, userinfo, loopback биш plain HTTP, нууц үгийн бодлого) домэйн болов. Authorization server бүтнээрээ платформд үлдэв. Энэ аппад урьд нь тест байгаагүй; татгалзал бүрийн өгүүлбэр одоо хүснэгтэд.

documents — зөвшөөрлийн гинжний дүрмүүд домэйн болов: нэг иргэн нэг удаа гарын үсэг зурдгаас урган гарах бүхэн, регистрийн дугаарыг Go-д том үсэг болгож руне-ээр тоолох шийдвэр, гарын үсгийн бодлого. Гарын үсгийн төмөр зам, PDF, retention нь байрандаа.

urtuu — Өртөө самбарын шийдвэрүүд домэйн болов: бүртгэлийн дугаарын хэлбэр, гарчиг ба хугацааг хэнээс тоолох, аль шугам өргөдөл эзэнтэй байхыг шаардах, мөчлөгийг таних, доод байгууллагын хоцорсон мэдээ дууссан ажлыг ухраахгүй байх. Суваг (холбоос, гарын үсэг, дараалал) платформд, гүйлгээт SQL адаптерт үлдэв. Энэ аппын дүрмүүдийг ажиглахад урьд нь хоёр суурилуулалт, түлхүүр, миграцлагдсан схем хэрэгтэй байсан.

Changed — Тайлангийн апп ба түүний хөдөлгүүр хоёр өөр зүйл болов

backend/domain/reports үүсэв: байгууллага аль тайланг үзэж чадах, хуваарь хадгалагдахаасаа өмнө юуг хангах, өөр байгууллагад тайлан үзүүлэхийг хэн зөвшөөрөх — эдгээр дүрэм HTTP handler-аас гарч, PostgreSQL ч, тайлангийн хөдөлгүүр ч байхгүйгээр ажилладаг боллоо. Хөдөлгүүр (internal/platform/reporting) байрандаа: тайлан ажиллуулах, экспортлох, товлон илгээх нь бүх модулийн хуваалцдаг платформ.

Апп нь өмнө нь нэг ч тестгүй байсан тул эхний алхам нь route-уудын зан төлөвийг барих гурван тест байв; тэдгээр өөрчлөгдөөгүйгээр ногоон.

Хоёр хариулт зориудаар засагдав: суулгасан аппын жагсаалт уншигдахгүй үед хоосон 200 биш 500 болов, мөн хуваалцах хүсэлт бүртгэхэд гарсан ямар ч алдааг «аль хэдийн хүсэлт байна» (409) гэж хэлэхээ болив — зөвхөн жинхэнэ давхардал 409.

Changed — Аппын дүрмүүд платформоо мэдэхээ болив

backend/domain/organisation үүсэв: сүүлчийн администратор үлдэх, нэгж өөрийн удмынхаа доор орохгүй байх, архивлагдсан эцэгтэй нэгж сэргэхгүй байх — эдгээр өгүүлбэрүүд HTTP handler дотроос гарч, pkg/nexus ч, chi ч, pgx ч импортлодоггүй пакет болов. Тэдгээрийг ажиглахын тулд өмнө нь миграцлагдсан PostgreSQL, HTTP хүсэлт хэрэгтэй байсан; одоо go test ./domain/... секундын дотор хариулна.

internal/apps/organisation нь адаптер болов: handler бүр декодлож, домэйнийг дуудаж, домэйний хариуг статус болгоно. Route, JSON талбар, HTTP статус, алдааны текст, эрхийн код, module ID — нэг ч байт өөрчлөгдөөгүй, аппын зургаан интеграцийн тест өөрчлөгдөөгүйгээр ногоон.

esign нь internal/platform/esign болов (Rails, Mount). Тэр нь 00058-аас хойш апп байгаагүй бөгөөд internal/apps/boundaries_test.go-д ганц онцгой тохиолдол шаардаж байсан шалтгаан нь зөвхөн хавтасны байрлал байв. Онцгой тохиолдлын хүснэгт одоо хоосон.

Шийдвэр ба түүний хил: docs/adr/0001-domain-first.md.

[1.9.1] - 2026-08-17

/api/v1/oauth2/consent sent already_granted: null when the user had granted that client nothing, because a Go nil slice is null and the handler set the field straight from a lookup that finds no row. The consent screen asks that list whether it includes each requested scope, so the page threw before it rendered: a relying party's very first authorization — the only kind a new client ever sees — ended at "This page couldn't load", with the grant itself never offered.

The handler sends [] now, through the same list helper the client writes already use, and the screen tolerates a null besides: a shell talks to deployments of several ages, and it should not be the half that breaks when one of them answers with an older shape.

[1.8.0] - 2026-08-17

Added — The installation ring and the platform's clock are SDK capabilities

Two things a module could only reach by living in this repository are published now, and the Өртөө app — the first caller of both — imports nothing from internal/ as a result.

nexus.Link is the Өртөө ring: enqueue a signed message for another installation, in the caller's transaction, and register a reader for what arrives. The transport stays in the platform for the same reason document filing does — one signing key, one outbox, one retry schedule and one set of peers per installation, not per app. Two apps growing their own would stop the ring being one ring the first time they disagreed about retries. What is deliberately absent: peering. Who this deployment is linked to is an operator's decision, and a module that could add a peer could arrange its own audience.

nexus.Location, with Now, Today, TimezoneName and DefaultTimezone, moved out of internal/platform/config, which now delegates to them. A module makes calendar decisions of its own — a register number carries a year, a report covers days, a schedule fires at an hour — and one reaching for time.Now() in the process's zone would put a Mongolian office's Monday morning on Sunday.

The Өртөө app took *urtuu.Service, a platform type, and used five of its methods; that was the whole of what stood between it and a repository of its own. It takes nexus.Link now.

Added — The core's shape is a test now, not a paragraph

Four apps have left this repository, and every one of those decisions was made in prose. The paragraphs are right and they were also the only thing holding the line: nothing failed when an app reached into another app's package, or when the platform reached into an app's. The split just got quietly more expensive, and the price was paid by whoever tried it next.

internal/apps/boundaries_test.go asserts the two properties that make a split possible — no app imports another app, and no platform package imports an app — and it found a real violation of each on the first run:

  • internal/platform/server.go imported the e-Government app to name egov.Rail, the shape it fills in with what this deployment is wired to. The comment said "egov names the shape, the platform answers it", which reads well and points the wrong way. The type moved to internal/platform/staterail, beside the clients that fill it in; the app imports it the way it already imports them.
  • The default-app installer test built the real organisation module. It is a test about the installer, so it registers a stub answering for that id instead.

One deliberate exception is recorded in the test with its reason: documents imports esign, because the PDF rails moved inside that app when the two store cards became one. Adding another entry is a decision, and it should feel like making one.

Changed — The commerce distribution is Gerege Business

commerce-gerege-nexus is business-gerege-nexus, the product is Gerege Business, and it answers at business.gerege.mn. The repository, the Go module path, the deployment names and the domain all moved together; the module ids did not, because an id is what an installation row, a manifest and a menu key are all keyed by, and a product changing its name is no reason for a database to lose track of an app.

Two names were deliberately left as they were, both for the same reason — they are not labels for the product, they are records of what has already happened:

  • the Postgres volume, still commerce-nexus_…, now pinned external in the compose file. Compose derives a volume name from the project, so renaming the project without pinning brings Postgres up on an empty data directory: healthy container, initdb, migrations, no tenants — a deployment that looks like it lost everything, because it has;
  • the goose_db_version_commerce table. It records what has run on that database; renaming it would show goose an empty history and have it apply the product's first migration a second time.

Nothing in the platform depends on either name. This entry is here because the next distribution to be renamed will face both.

Added — A deployment can supply its own app icon

BRAND_ICON_URL and BRAND_MASKABLE_ICON_URL join the brand: the tab favicon, the Apple touch icon and the manifest's icon all follow the first, and the second is published only when a deployment has artwork drawn for cropping.

When the brand work landed, icons were the one thing deliberately left in the image — "the logo is an address, the icons are files". That was half true. The files cannot be handed to a built image; the addresses can, exactly as the logo's is, and a deployment serving its own logo already has somewhere to put one. Nothing is resized or generated here: the platform points at what it is given.

  • Two variables, not one, because a maskable icon is not a resize. Android crops an adaptive icon to a circle, so artwork that does not bleed to the edge with its subject inside a safe zone comes out with its corners missing. Unset publishes no maskable entry at all, and the launcher crops the ordinary icon itself — visibly a crop rather than quietly wrong.
  • A supplied icon is declared sizes: "any". The deployment's file is whatever size it is and nothing here can measure it; claiming 512×512 over a 144-pixel picture is a claim the browser believes and then draws blurred.
  • The favicon moved from app/favicon.ico to public/. As a file convention Next emits a <link rel="icon"> for it unconditionally, so a deployment that named its own ended up with two icon links and which one the browser drew was a coin toss. In public/ it is an ordinary file at the same address, named in the metadata when nothing overrides it — exactly one link either way.

Fixed — Гарын үсэг зурагдсан мөр наносекундаа алдаж байсныг зассан

Гарах дугтуйн created_at нь TIMESTAMPTZ баганад хадгалагдаж, илгээхээр уншихад дахин дүрслэгддэг байв. Go-гийн time.Now() Linux дээр НАНОсекунд өгдөг, Postgres МИКРОсекунд хүртэл хадгалдаг тул буцаж гарсан мөр нь гарын үсэг зурагдсан мөрөөс өөр болж, дугтуй бүр нөгөө талд унаж байсан.

Хөгжүүлэгчийн macOS дээр цаг ихэвчлэн микросекундээр зогсдог тул харагдалгүй, CI (Linux) дээр Өртөөгийн бүх тест улаан болж илэрсэн. Хамгийн муу хэлбэр: бүх зүйл зөв харагдаж, сувагт юу ч хүрэхгүй.

payload-ыг яагаад JSONB биш TEXT болгосон шалтгаан яг энэ байсан бөгөөд created_at дээр хийгдээгүй байжээ. Одоо гарын үсэг зурагдсан мөрийг яг тэр чигээр нь хадгална (миграци 00067) — дахин дүрслэгдэхгүй тул дахин зөрөх боломжгүй. Тэр багана SQL-д ашиглагддаггүй байсан: цорын ганц уншигч нь дугтуйг эргүүлэн угсрах код.

Тест нь цагаас хамаарахгүй: наносекундтай тогтмол агшин бичиж, хадгалаад буцааж уншаад гарын үсгийг нь шалгана.

Fixed — Платформын календарь Улаанбаатарын цагаар

Агшинг өдөр болгож бууруулах бүх газар нэг цагийн бүсээр: Asia/Ulaanbaatar (PLATFORM_TIMEZONE-оор дарж болно). Хадгалалт өөрчлөгдөөгүй — багана бүр timestamptz, агшин хадгалсаар байна; энэ нь зөвхөн түүнийг ХЭРХЭН УНШИХЫГ шийднэ.

Илэрсэн алдаа. Хэрэглээний тоолуур өдрийг Go талд Format("2006-01-02")-оор гаргаж, нэгтгэлээ created_at::date буюу өгөгдлийн сангийн бүсээр хийж байв. UTC+8 машин дээр шөнө дундаас өглөөний 8 хүртэл хоёр нь зөрж, бүх тоо тэг гарч, шалтгааныг хэлэх зүйл байхгүй байсан. Консол дээр "энэ байгууллага юу ч хийгээгүй" гэж харагдана — квота мөн адил тэр тоог уншина.

Засвар нэг л газар. dbguard.Install connection бүрт цагийн бүсийг өгдөг болов. Тэр нь энэ репо дахь ЦОРЫН ГАНЦ бүх pool тохируулагддаг газар — production болон өгөгдлийн сантай тест бүр — тул хоёр дахь дуудлага мартагдах боломжгүй. Үүнээс хойш ::date, CURRENT_DATE, date_trunc бүгд платформын цагаар уншина.

Go талд: тоолуурын хуваарь (шөнө дунд), тайлангийн огнооны муж (хэрэглэгчийн бичсэн «2026-08-16»), товлосон тайлангийн cron цаг, Өртөөгийн бүртгэлийн дугаарын он, баримтын загварын {date} — бүгд config.Location()-оор.

Нэг нарийн зүйл тэмдэглэв: $1::date гэж бичвэл Postgres параметрийг шууд date гэж таамаглах тул драйвер Go-гийн бүсээр огноог бууруулж, алдаа зүгээр л нүүдэг. $1::timestamptz::date нь параметрийг агшин байхад хүргэж, бууралт нь session-ий бүсэд явна. Тестээр батлав: алдааг буцаахад унана.

time/tzdata компайлд шингээв — суурь image-д tzdata байхгүй бол LoadLocation унаж, чимээгүйгээр UTC болох байсан.

Added — «Өртөө»: платформ хоорондын үйлчилгээний хүсэлт, даалгаврын суваг

Nexus дээр суурилсан платформууд дээд/доод холбоосоор хэлхэгдэж, урьдчилан бүртгэгдсэн хүсэлтийн кодоор даалгавар доошоо урсаж, биелэлт нь дээшээ буцна. Яамнаас гарсан «хагас жилийн тооллого» агентлагт ирж, аймгуудад задарч, сумдад буугаад, биелэлт нь шат шатандаа хуримтлагдан яаманд харагдана. Их Монгол Улсын өртөө шуудангийн зарчим: захиаг өртөөнөөс өртөөнд дамжуулж, хүрсэн эсэхийг нь буцааж мэдэгддэг.

Дизайны санал docs/URTUU_PROPOSAL.md, ажиллагааны заавар docs/URTUU.md.

Гурван давхарга, зааг нь тодорхой. pkg/urtuu — гэрээ (дугтуй, гарын үсэг, статусын машин), distribution бүр ижил ойлголттой байхын тулд pkg-д. internal/platform/urtuu — тээвэр (холбоос, дараалал, retry), платформын үйлчилгээ. internal/apps/urtuu — апп (io.gerege.nexus.urtuu), тенант өөрөө суулгана. Аппыг устгасан ч замд яваа ажил алга болохгүй: суваг нь доор нь үлдэнэ.

Доод тал л холбогдоно. Дээд тал доод руу хэзээ ч гарахгүй — доод суулгац галт ханын цаана, хувийн сүлжээнд, түр унтраатай байж болно. Доод тал long-poll-оор татаж, биелэлтээ түлхэнэ. Каталог sync яг ижил шалтгаанаар pull сонгосон.

Token нь "хэн ярьж байна", гарын үсэг нь "хэн бичсэн". Хоёр өөр асуулт, дугтуй бүр дээр хоёулаа асуугдана. Гарын үсэг нь pkg/catalog-ийн signed баримттай яг ижил хэлбэртэй (created_at + '\n' + түүхий payload), golden тесттэй. Дугтуйн payload нь өгөгдлийн санд JSONB биш TEXT-ээр хадгалагдана: JSONB нь түлхүүрийн дарааллыг эмхэлдэг тул хадгалаад буцааж уншихад гарын үсэг батлагдахаа болих байв.

Хоёр талын зөвшөөрөл, устгах биш цуцлах. Дээд тал урина (24 цаг, нэг удаагийн код) → доод тал буулгаж түлхүүр солилцоно → дээд тал баталгаажуулна. report_grants-ийн урсгал. Цуцлагдсан холбоос 401 буцаана, баталгаажаагүй нь 403 — цуцлагдсан тал нь өөрийгөө "хэзээ ч байгаагүй"-ээс ялгаж чадах ёсгүй.

Кодын бүртгэлийн стандартыг бид тогтоов. ring.dgov.mn-ий утасны форматыг хүлээхийн оронд түүнийг санал болгож бичив: docs/RING_STANDARD.md — гарын үсэгтэй баримт, ETag, Ed25519. Гарын үсгийн вход нь аппын каталог болон Өртөөгийн дугтуйтай ЯГ ИЖИЛ (generated_at + '\n' + түүхий байт): нэг дүрэм гурван газар, нэгийг нь уншсан хүн гурвуулангийнхыг ойлгоно. Хэрэгжилт нь бүрэн — нөхцөлт татах, гарын үсэг шалгах, хэмжээний хязгаар, нэг муу бичлэг бүх импортыг унагаахгүй.

Гурван зүйлийг ЗОРИУДААР хийгээгүй: диск кэш (импортлогдсон кодууд өгөгдлийн санд сууна — тэр нь өөрөө кэш), хуваарьт синк (код нь хугацааны нормтой ирдэг, норм нь ажилтны хэмжигдэх зүйлийг өөрчилдөг тул авах эсэх нь байгууллагын шийдвэр), гарын үсэггүй горим (RING_PUBLIC_KEY байхгүй бол импорт огт байхгүй — шалгагдаагүй бүртгэл нь улсын бүх суулгац дээрх нормыг өөрчлөх эрхтэй баримт).

Ring нь ЭРСДЭЛ БИШ, НЭМЭЛТ: RING_BASE_URL тохируулахгүй бол платформ бүрэн ажиллана — байгууллага local. кодоо зохиож, холбоос бүрд зарлана.

Даалгавар чөлөөт текстээр үүсэхгүй. Урьдчилан бүртгэгдсэн кодоор үүснэ: код нь юу бөглөхийг (JSON Schema) болон хэдий хугацаанд хийхийг (SLA) өөрөө хэлнэ. Эх сурвалж нь ring.dgov.mn — импортлогч интерфэйсийн цаана, бодит формат тохирогдоогүй тул parsing нь TODO хэвээр (таамгаар бичсэн parser өөрийн тестээ давж, бодит цэг дээр буруу байна). Дээд тал холбоос бүрд аль кодыг нээхээ шийдэж зарлана; локал код заавал local. угтвартай.

Мөчлөгөөс хамгаална. Даалгавар бүр дамжсан суулгацынхаа ID-г origin_chain-д авч явна. А→Б→А холбоос өөрөө хууль ёсны (хоёр яам өөр өөр төрлийн ажлаар бие биенийхээ дээд байж болно) — хамгаалагдах ёстой нь холбоос биш, даалгавар. Гинжинд өөрийн ID байвал шалтгаантай буцаана.

Хоёр шугам, хоёр амлалт. Суваг нь хоёр төрлийн ажил зөөнө. Үйлчилгээ: иргэн, байгууллагаас ирсэн хүсэлт доошоо явж, ХАРИУ нь заавал буцна — хүсэгч платформын гадна байгаа тул хариугүй хаагдсан хүсэлт гэдэг нь тэр хүний асуултыг зүгээр л алга болгосон хэрэг. Албан даалгавар: дээд байгууллага доод байгууллагадаа ажил өгнө, хүсэгч гэж байхгүй.

Хоёр дахь мөр нь Go-гийн шалгалт биш, СХЕМИЙН шалгалт: үйлчилгээний хүсэлт хариугүйгээр COMPLETED болж чадахгүй (urtuu_tasks_service_has_answer). Handler дээрх шалгалт нь хүнд ойлгомжтой өгүүлбэр буцаахын тулд; CHECK нь мартагдахгүйн тулд.

Шугам нь КОДООС ирнэ, үүсгэгч хүнээс биш: ring.dgov.mn-ээс импортлогдсон код бол төрийн үйлчилгээ, дотоод код бол даалгавар. Үүсгэгч сонгодог байсан бол нэг код хоёр өөр амлалттай хоёр газар хэрэглэгдэх байв. Задаргаа хийсэн хүсэлтийн хариу нь салбар бүрийн хариуг байгууллагын нэрээр нь нэрлэн эх мөр дээр цуглана.

Албан бичиг. Даалгаварт eID гарын үсэгтэй бичиг дагалдана. Дамждаг зүйл нь ЛАВЛАГАА — баримт нь бүртгэсэн байгууллагын Баримт бичиг апп дотор үлдэж, тэндээ гарын үсэг зурагдана; холбоосоор "ийм бичиг байгаа, ингэж нэрлэгдсэн, гарын үсэг хэд зурагдсан" гурав л явна.

Бүртгэлийн дугаар. Мөр бүр хүн уншихуйц дугаартай: Д2026-00412 (даалгавар), Ү2026-01875 (үйлчилгээ). Эхний тэмдэгт нь шугам — дугаарыг харангуут хариу заавал буцах эсэхийг мэднэ. Дараалал нь суулгац, шугам, он тус бүрд. Шат бүр ирсэн ажлыг өөрийн бүртгэлд дугаарлаж, илгээгчийн дугаарыг иш татна — цаасан бичиг яг ингэж ажилладаг.

Дугаарт платформын угтвар ЗОРИУДААР байхгүй: холбоосын мөр аль хэдийн name талбартай ("Ховд аймаг"), админ handshake хийхдээ өгсөн. Суулгац тусад нь богино код зарлавал нэг зүйлийн хоёр дахь, хэн ч баталгаажуулдаггүй нэр үүсэх байв. Дугаарыг нөгөө талын нэртэй нь хамт харуулна: Ховд аймаг · Д2026-0087.

Хяналт. urtuu_tasks_total{status}, urtuu_deliveries_total{result}, urtuu_peer_last_seen_seconds{role} — тенантын label аль нь ч дээр байхгүй. Операторын консол дээр «Өртөө» гэрэл: хүргэгдээгүй дугтуй, дуугүй холбоос. Оператор агуулгыг харахгүй — миграци 00064 нь зөвхөн urtuu_peers, urtuu_deliveries-ийг нээнэ.

Тайлан гурав: даалгаврын биелэлт, хугацаа хэтрэлт, сувгийн ачаалал. reports.schedule-тэй ажиллана. Гурвуулаа ScopeFull зарлана, ScopeCounterparty зарлахгүй: даалгаврын схемд counterparty_ref байхгүй бөгөөд холбоосыг түүн мэт үзвэл нэг байгууллагад өгсөн зөвшөөрөл чимээгүйгээр бүх доод платформын харагдац болох байв.

Миграцууд 0006100067. Шинэ орчны хувьсагчид: URTUU_SIGNING_KEY (байхгүй бол Өртөө унтраалттай, платформ хэвийн асна), URTUU_ALLOW_INSECURE_PEERS, RING_BASE_URL, RING_API_KEY.

[1.7.0] - 2026-08-15

Changed — Contacts left for the commerce distribution, and the sidebar was rearranged

Three changes with one shape: putting each thing where it belongs.

The contact register is a product's, not a platform's. Migration 00059 folded Contacts into the Directory this morning, on the argument that who an organisation is made of and who it deals with are one subject. That was half right, and the wrong half is the half that decides where code lives: departments and staff are something every organisation has; customers are something a business has. So io.gerege.nexus.contacts is an app again — built and shipped by commerce-gerege-nexus — and io.gerege.nexus.organisation is the organisation again at 3.0.0, under the name it had before the merge.

  • The version does not go back to 1.0.0. 2.0.0 was published and deployments installed it; a catalogue offering 1.0.0 to them would be offering a downgrade nothing knows how to apply.
  • The contacts table stays, for the reason applied migrations always stay: 00003 has run on every deployment in the field, and the module reading it is the same code at a different import path.
  • The grants stay. 00059 gave organisation.read/manage to every role that held contacts.read/manage; taking them back would remove a permission from roles that may have been edited since. An administrator can drop one they do not want and cannot restore one nobody told them had gone. Migration 00060 fixes the only thing that was actually wrong — the two descriptions 00059 widened to mention contacts.
  • The screens stay in the shell and moved back to /module/contacts/*, where the module now points its own menu entries. A blueprint is keyed by app id inside the platform, so a distribution cannot add to one — which is right: the platform should not carry a list of screens for products it does not ship.

The sidebar says where things are. Installed apps sat under Modules while its page is at /settings/apps, which asked somebody to hold two answers for where the same screen lives; it is under Settings now. The organisation moved the other way, up into Modules, because it is a thing you look at and edit rather than a switch that changes how the platform behaves — and its two screens, departments and people, are indented beneath it instead of listed beside the App Store as though the three were unrelated destinations.

[1.6.0] - 2026-08-15

Added — A distribution can read its own catalogue

catalog.LoadFile and the pieces under it — LoadEntries, Assemble, LoadManifest, LoadChronicleFile, ReleaseNotesFor — moved from internal/platform/appcatalog into the contract package.

The App Store found this the way these things are always found. A test wanting to check its bundled catalogue against the modules compiled beside it could not read the catalogue: the loader was in internal/, which is the rule that makes distributions possible, working exactly as designed and against the product it was designed for. The alternative was a second implementation of "how a catalogue is read" — a second answer to whether a manifest is valid, agreeing with the first until the day a deployment and the store it publishes to disagreed about an app nobody had touched.

The platform's own loader is now three lines over the public one, keeping the one thing that is genuinely this repository's: the deprecated app-id renames, which no distribution should inherit.

[1.5.0] - 2026-08-15

Added — Apps can be published to named platforms instead of to all of them

An app now declares a visibility: public, which every platform may be offered, or private, which only the platforms the registry names may be. Empty means public, so every manifest written before this marshals to the bytes it always did — the signed catalogue stays byte-reproducible across the two repositories that build it.

It is enforced by the registry, not by the platform, and that is the whole design rather than a shortcut. A private app is kept from a platform by not being in the catalogue that platform is served. The alternative — ship every platform the same document and ask each to hide what it should not see — leaks the names of private apps to everyone holding the catalogue, and asks the party with the motive to look to be the party that decides.

  • The declaration travels. visibility moved onto the manifest. cmd/publish-catalog submits exactly that struct, so a visibility that lived only on the catalogue entry was a declaration the publisher made and nobody downstream ever received. The entry keeps its field; a private declaration in either half wins, because an app hidden by mistake is a support question and an app published by mistake is not recallable.
  • An unknown value is refused. Not read as public, which would publish an app on a typo — Private, internal, restricted — and not read as private, which would hide one for a reason nobody could see.
  • A deployment can now identify itself to the registry: APP_CATALOG_TOKEN, sent as a bearer token on the catalogue request. A signature says who wrote a document; it says nothing about who should be reading it, and both matter once an app is published to named platforms. A request without it is anonymous and can only be answered with what everybody may see. Empty stays the normal case — a deployment granted no private apps has nothing to prove — and the token is never put on the query string and never written to the catalogue cache.
  • The store card says Private on an app that arrived by arrangement. Nothing else on it distinguishes such an app from one anybody can install.

The other half of this lives in appstore-gerege-nexus: which platform may see which private app, and GET /catalog answering per deployment. One trap to carry over — the ETag has to vary per deployment too, or a platform whose entitlement is withdrawn keeps its old catalogue on a 304. See docs/ECOSYSTEM_GIT_STRATEGY.md §3.1.

Fixed — Installed apps listed four that this binary cannot run

The settings screen read straight from app_installations, so it showed nine rows under its own banner saying the catalogue has five. State Services, Products, Inventory and Billing were all listed as installed and active — months after their code left for gov-gerege-nexus and commerce-gerege-nexus — each with a button offering to disable an app that was not running. Nothing about them worked: no routes mounted, no menu entry, and the compile-time check refuses them at install. The row was all that was left, and a row that says "Active" about an app with no code is worse than no row.

The store already had this rule (runnableHere, added when State Services left); the list of what a tenant has never got it. Both screens ask the same question now: if the catalogue knows the app, it has to be runnable here; if the catalogue has never heard of it, a compiled module is enough — a distribution's own module is real from the moment the binary starts and may reach a catalogue minutes later or never.

The rows stay in the database. The apps went to distributions this deployment may yet run, and deleting an installation because a screen cannot render it is the wrong way round.

Changed — Two cards became one app: Contacts moved inside the Directory

"Organisation & People" and "Contacts" were one subject cut in half — who this organisation is made of, and who it deals with. An administrator who installed one and not the other had half a directory, and nothing in the store said which half was missing. So io.gerege.nexus.contacts is gone and its register is part of io.gerege.nexus.organisation, now called Directory (mn: Бүртгэл) at version 2.0.0.

The contacts table, the API path /api/v1/contacts and the screen at /contacts are all unchanged. What moved is the app the register belongs to.

  • The contact routes assert their own permission now, and this is the part worth reading twice. They were gated by the platform, from the registered module's route prefix — and a module that mounts another package's routes lends it its own gate. The Directory declares no prefix, because it checks each of its routes explicitly. Mounting the register behind it without saying so would have turned "contacts.manage required" into "any member of the tenant", silently, in a diff about menus.
  • One permission namespace. contacts.read/manageorganisation.read/manage, one to one, carried by migration 00059 before the old codes are dropped.
  • The register arrives everywhere. The Directory is a default app, so every tenant has it — including tenants that never installed Contacts. That is the thing being fixed rather than a side effect: a directory with no outside half was the half-product this merge exists to end.
  • Migration 00059 was run against a database in the pre-merge shape — a role holding only the two contacts codes, a tenant with Contacts and no Directory row at all — and taken down and up again. The role came out holding exactly the two organisation codes; the tenant came out with the Directory installed and enabled.
  • catalog/chronicle/contacts.json is kept and marked retired, as esign.json was.

Changed — Two cards became one app: PDF E-Sign moved inside Documents

The store carried "Digital Documents & Signatures" and "PDF E-Sign" side by side. They answered one question — where are my documents and who has signed them — and nobody adopts a signature on its own; they adopt it because something has to be signed. So io.gerege.nexus.esign is gone and its rails are part of io.gerege.nexus.documents, now called Documents (mn: Баримт бичиг) at version 2.0.0.

Nothing about how a document is signed changed. The eID and HSM rails, the signature log, batch signing, stamp placement, the HSM connection and the two reports are the same code reading the same tables.

  • esign is no longer a module. It registers nothing with nexus; documents.New builds it and documents.RegisterRoutes mounts it. The classification test in internal/apps grew a third category for exactly this — a package that stops being a module has to say so somewhere, or its absence from the table reads as an oversight.
  • One permission namespace. esign.read/sign/managedocuments.read/sign/manage, one to one. Migration 00058 carries every existing grant across before the old codes are dropped, so no administrator is asked to reconstruct anything. An app with two namespaces would have made someone grant the right to sign twice, in two places, with no way to tell from the Access control screen which one the button obeys.
  • documents.sign now reaches the default manager and user roles, which esign.sign did and record signing did not. The argument was always the same one: the authority to sign is the citizen's own — PIN2 on their own phone, or a certificate proved to the HSM — and an approval chain only counts a signature from somebody it names. Withholding it only stopped people signing their own documents.
  • The API keeps /api/v1/esign. A path is a contract with every client already written against it, including the reference signing view the eID rail mirrors. What merged is the product, not the wiring. The screen did move: /esign answers 308 to /module/documents/pdf, because one app has one slug and every screen hangs off it.

The forwarding note is served by proxy.ts rather than by a page calling redirect(), and the difference is not academic: the root layout is rendered per request and streams, so a nested page asking for a redirect gets a 200 carrying a client-side instruction — fine for a browser, useless to a crawler or anything reading the status code, which is most of what a permanent move is announced to. This was shipped the wrong way round first and found by curling production. - The menu gained the PDF entries and lost a collision: two screens were called "Signature policies". One is which channel a document type may be signed through; the other, now "PDF signing rails", is which of the two PDF machines is switched on. - Migration 00058 was run against a database holding the pre-merge shape — a tenant with the PDF app and not the documents app, a manager role holding all three old codes, a report schedule naming an old key — and then taken down and up again. What the down cannot restore is which tenants had the PDF app separately; that left with the deleted rows, so a rollback gives it back to everyone holding Documents. The safe direction: an app to switch off rather than work nobody can reach. - catalog/chronicle/esign.json is kept, marked retired rather than deleted. Two versions really shipped, and removing them buys a tidier directory at the price of a record that is no longer true.

Changed — The image no longer knows its own name

lib/apiBase.ts took the deployment's address out of the build. This takes its identity out, which is the second half of the same argument: one image, a different .env, a hundred deployments is only true when neither is baked in.

Five values are read from the environment per request — BRAND_NAME, BRAND_SHORT_NAME, BRAND_DESCRIPTION, BRAND_LOGO_URL, BRAND_THEME_COLOR — and all of them are optional. Unset is Gerege Nexus, so nothing about nexus.gerege.mn changes. They reach the document title, the PWA manifest (which is what an installed copy keeps under its icon), the header, the sign-in and consent screens, the workarea footer, the operator console and the landing page's chrome.

  • The product's name became a variable in the dictionary. It was written out in nineteen entries and their overlays in five more languages, which made a rebrand a translation job in seven. t() now substitutes {brand} into every string without being asked: a sentence that names the product is ordinary prose, and requiring every call site to pass the value would mean the one that forgot renders {brand} at somebody.
  • The shell's HTML is rendered on demand rather than prebuilt. That is the price and it is the point — HTML produced by next build is a name travelling inside the image. Little was lost: every screen under this layout is either behind a session or already rendered per request, so what was being cached was the empty frame around them. The root layout is a server component now and the providers moved to app/providers.tsx; a comment there once called that split larger than metadata warranted, which it was, for metadata.
  • The mark is an address, not an import. It used to arrive as a static import with a hashed, permanently cacheable URL, which is the same thing as belonging to the build. BRAND_LOGO_URL is validated as a path on this host or an absolute http(s) URL and ignored otherwise — the value ends up in an img tag, and a mistyped environment file should cost a logo rather than anything else.
  • The API reads BRAND_NAME too, for the only two places it names the product to a person: the message shown when a verified eID identity cannot be linked, and the relying-party name eID Mongolia puts in front of a citizen approving a request. Set it in both containers or in neither.
  • Not made configurable, deliberately: the icon set, which is files rather than an address; the accent palette, which is a per-person, per-device preference in Appearance and not a deployment's to seize; and the native shells, which are signed bundles carrying their own name — the export script resolves {brand} from BRAND_NAME so no placeholder reaches them.
  • The offline page lost the product's name instead of gaining a variable. It is precached and served with no network, so nothing can tell it what this deployment is called; it now says "this app", which is true everywhere and is addressed to somebody staring at an icon they installed themselves.
  • The head tags moved from hand-written elements to Next's metadata API, which a server component can use, and one of them nearly did not survive the move: appleWebApp.capable emits only the unprefixed mobile-web-app-capable, and Safari on iOS honours nothing but Apple's spelling for a standalone launch. Written by hand alongside it, as it was before. An installed copy that came back inside Safari's chrome would have been a strange thing to trace to a refactor of the title.

See docs/ECOSYSTEM_GIT_STRATEGY.md §2.3 and §6.

Removed — State Services left, and is a product of its own

The second distribution split. apps/gov_services now lives in gov-gerege-nexus.

No composition image, and nexus.gerege.mn simply stops offering the app. That was not the plan an hour before it shipped; the plan was a repository whose only job is to build core-plus-verticals into one image for this deployment. Production answered the question instead. It carries two tenants; the app is installed on one of them and disabled there; gov_services, gov_applications and gov_appointments hold zero rows between them. A repository, a pipeline and their maintenance, to keep showing an app nobody has switched on. The ecosystem strategy tells distributions to choose the lower level when in doubt (§1), and the rule reads the same when it is pointed at us.

The distribution exists and is green, so the day somebody wants State Services it is a deployment rather than a project.

  • Gone from here: the module package, catalog/manifests/gov-services.json, its chronicle, its entry in catalog/apps.json, and its menu blueprint.
  • Migrations 00006 and 00007 stay, for the reason 00038 stayed when the App Store left: they have run on every deployment in the field, and removing an applied migration buys a tidier directory at the price of a history that no longer describes the database.
  • The frontend pages stayapp/module/gov-services/* and components/gov/*. The shell is one image serving every deployment, so it carries the union of first-party screens; without the module behind them the pages are inert, unlisted in the menu because menus are built from registered modules, and refused by the API. See docs/ECOSYSTEM_GIT_STRATEGY.md §2.3.
  • The stale app_installations row on that one tenant is harmless: the compile-time check runs on install, not at boot, so a row without code mounts no routes and lists no menu.

[1.4.0] - 2026-08-15

Removed — Commerce left, and is a product of its own

The third and last of the planned vertical splits. apps/products, apps/inventory and apps/billing now live in commerce-gerege-nexus.

As with State Services, no composition image: production carries two tenants, and products, stock_levels and billing_invoices hold zero rows between them. A deployment that wants commerce runs the distribution.

  • Migrations 00003 and 00004 stay, and the distribution re-declares the five tables rather than moving them. It could not move them: 00003 creates contacts — which the platform keeps — in the same file, and 00004 creates sessions and oauth2_clients beside the invoices. This entanglement, not the foreign keys the plan blamed, is why commerce was last.
  • Every index in the distribution's copy is IF NOT EXISTS. The originals were not: CREATE INDEX idx_products_tenant with no guard is harmless when a migration runs once against an empty database, and fatal in a history that runs against databases already carrying the platform's copy of these tables.
  • invoices_created_total registers tolerantly. During a split the module is compiled twice — once inside the platform it depends on, once in the distribution — and MustRegister turns that into a panic at init, before any logging, nowhere near the cause. A module should not stop a binary from booting because an older copy of itself was compiled alongside.
  • The classification guard now reads the directory. It compared two hand-written constants, which would have passed while these three modules were deleted, because both numbers get edited in the same breath. It now fails in both directions and both were demonstrated.
  • The frontend pages stay, for the reason they stayed for State Services: the shell is one image serving every deployment.

[1.3.0] - 2026-08-15

Added — the reporting contract and the document capability, both so a module elsewhere can use them

  • nexus.Report and the shapes around itQuerier, Rows, ParamSpec, ColumnSpec, Params, Result, plus RegisterReport and UseReportSink. A module needs the authoring contract, not the engine; parameter binding, totals, exports, schedules and cross-organisation grants stay inside the platform where they can change without moving the ecosystem's floor. internal/platform/reporting is now aliases onto these, so there is one set of types rather than two kept in step by hand.

The sink buffers, unlike UseAuditSink which drops. A dropped audit line is a gap in a log; a dropped report is a feature that silently does not exist, indistinguishable from one nobody wrote. Buffering makes the order of a distribution's main() stop mattering.

  • nexus.DocumentFiler — filing a document and following what becomes of it, available to every module including those compiled elsewhere. There is deliberately no Sign: a signature is an interactive ceremony performed by a person in front of a screen, and a module that could sign would be a module that could sign as somebody else. How many signatures a document needs is the tenant's policy, not the caller's, so it is not a field on the draft.

Documents and esign stay in the platform. The dependency profile settles it: esign reaches into the eID rail, the DAN rail, the HSM, the registry client, quota and the async runner. It is not an app that happens to live here; it is a platform capability wearing an app's clothes, the same shape as reporting.

Changed

  • invoices_created_total is registered by the billing module, not by the platform, under the same name the dashboards already use. A platform that ships a counter named after somebody else's domain has to be edited every time that domain moves. A deployment without billing does not export the series at all, which is truer than a zero that never moves.
  • products, inventory and billing no longer import anything under internal/. That is what makes the commerce split possible.

[1.2.0] - 2026-08-15

Added — a module can now state its own access policy, and book a meeting

Both are the same discovery from two directions: the platform held things a module should have held, and a module could not be moved until it did.

  • nexus.AccessPolicyMenuPermission() and RoutePermissionPrefix(), optional, empty being a real answer rather than an omission. The platform used to hold this in two switch statements keyed by app ID, listing every app by name. A module in another repository cannot add itself to a switch in this one, and the failure would not have been a compile error: an extracted app keeps working, keeps appearing in the sidebar, and stops being gated. products, inventory and billing were in both switches, so a commerce split done before this would have removed route permission checks entirely. The switches are now assertions in internal/apps/access_policy_test.go.
  • nexus.MeetingBooker, MeetingConnector, Meeting — booking a conferencing link, as a module sees it. gov_services declared its own interface and inverted the dependency correctly, but the interface spoke in *integration.Connector and *integration.Meeting, which are under internal/. A dependency's type travels as far as the dependency does, so that module could not be compiled outside this repository — and the connector it could not do without is a fifteen-field storage record of which it reads one field. integration.AsMeetingBooker adapts the manager to the contract.
  • Both additions are additive; a module compiled against 1.1.0 keeps compiling.

gov_services now depends on pkg/nexus and nothing else from this repository, which is what makes the next split possible.

Removed — the App Store left, and is a product of its own

The first distribution split. apps/appstore_registry, apps/publisher_studio and apps/store_review now live in appstore-gerege-nexus, which takes this platform as a dependency by tag and adds nothing but three modules and the line that registers them. Every other deployment stopped carrying them as dead weight the day they left.

  • Gone from here: the three module packages, catalog/profiles/appstore, cmd/appstore-import (an operational tool for those tables), and the App Store's entries in the public-route list. That last one matters: a name on that list is a permission, and leaving /api/v1/registry/* behind would have blessed the next core route that happened to be mounted under it.
  • Migration 00038_appstore_registry.sql stays. It has already run on every deployment in the field, and removing an applied migration from the sequence buys a tidier directory at the price of a history that no longer describes the database. The tables sit unused where the App Store is not installed. New store migrations belong to the distribution, in its own goose table.
  • The distribution needs a route-policy guard of its own. It has public routes — the signed catalogue and the keys that verify it — and nothing there is checking them yet.

[1.1.0] - 2026-08-14

Added — pkg/platform, so a distribution can start the platform it compiles against

The SDK let somebody write a module and gave them nowhere to run it: booting is internal/platform.NewServer, which the language closes to every other repository. docs/ECOSYSTEM_GIT_STRATEGY.md §2.5 sketches a distribution's main.go as a call to platform.Run(); this is that function.

  • backend/pkg/platform.Run(Options) is the whole boot — configuration, tracing, the pool with the isolation guard installed on it, the invalidation bus, the server, the background sweeps, the listener, a graceful stop. It is cmd/api/main.go moved, not reimplemented, and cmd/api is now three lines calling it. That is deliberate: the platform's own binary is built the way every distribution's is, so a boot path that works here works there.
  • Options.Modules registers a distribution's own modules with the same nexus.Platform the built-in ones get, at the same moment — after the pool exists and before any route is mounted. NewServer takes it as a variadic option, so every existing caller and test kept compiling.
  • A dead listener now unwinds through shutdown. It used to call os.Exit(1) from inside the goroutine, which skipped every deferred close on the way out — the pool, the Redis client, the tracer's flush. It now hands the error back and leaves by the same door a signal does.
  • cmd/migrate accepts MIGRATIONS_DIR and MIGRATIONS_TABLE. A distribution has its own schema, and goose keeps one row per applied version in one table, so its 00001 and the platform's 00001 were the same row. Each history needs its own table. Defaults unchanged.

Added — pkg/catalog, the app-store contract, and a clean appstore boundary

Preparation for the first distribution split (gerege-appstore). The three store modules — the registry, the publisher studio and the review queue — were measured against the rest of the platform first, and the boundary turned out to be almost clean already: nothing in the core imports them, and the only thing holding them here was the catalogue schema, which lived in internal/platform/appcatalog where no other repository can reach it.

  • New public package backend/pkg/catalog carrying the schema and its validation: Manifest, CatalogApp, Chronicle, ReleaseNote, Person, ValidateManifest, ValidateChronicle, ValidateCatalog, IsNewerVersion, and the slug rule. docs/ECOSYSTEM_GIT_STRATEGY.md §2.4 names this one of three contracts that outlive the core's release cycle; it is separate from pkg/nexus because its audience is different — a registry operator or a third-party publisher, not somebody writing a Go module.
  • What stays in internal/ is anything that knows where a catalogue lives on a particular deployment: the bundled file, the disk cache, the signed fetch from a registry, and the rename table.
  • IsValidSlug moved with the schema. What counts as a slug is part of the app-store contract — a slug is a store URL segment and a manifest filename — rather than of this deployment's hardening. security.IsValidSlug forwards, so the two can never disagree about the one app that could be published and not installed.
  • Four unused interfaces deleted (CatalogRepository, PackageStorage, PackageVerifier, Installer), left over from a sketch of "the future marketplace boundary". Nothing implemented or called them, and carrying them into a public package would have frozen four shapes nobody uses into the semver promise.
  • The same import-graph guard as pkg/nexus: the catalogue contract may not reach internal/.

The three store modules now import pkg/nexus, pkg/catalog and each other, and nothing else from this repository. What still ties them here is three lines in internal/apps/runtime.go — the lines that become a distribution's main.go.

[1.0.0] - 2026-08-14

Эхний тогтвортой хувилбар: backend/pkg/nexus нь semver амлалттай нийтийн API болж, экосистемийн салгалтын 0-р (нэршил) ба 1-р (SDK) алхам дуусав. Энэ хувилбараас эхлэн distribution repo нь цөмийг fork хийхгүйгээр dependency болгон авч чадна — хувилбар гаргах журам.

Added — a release process, and the tests that make its promise checkable

Step 2 of the ecosystem split. A tag is the only way another repository can depend on this one, so a tag has to mean something; this is what makes it mean something.

  • docs/RELEASING.md — the semver promise in plain terms (what breaks a caller and what does not, with the interface-versus-struct distinction spelled out because it is the one people get wrong), the backend/vX.Y.Z tag form Go requires of a module in a subdirectory, and the procedure.
  • The exported API of pkg/nexus is a golden file (pkg/nexus/testdata/api.txt, 66 lines — the whole ecosystem contract on one page). Changing it is allowed and often right; changing it by accident is not. go test ./pkg/nexus -update re-records it, and the diff is what a reviewer reads. Without this the promise would be broken not by anybody deciding to break it but by a rename during a refactor, or by a method added to an interface a distribution implements — neither of which fails a test here, and both of which fail in somebody else's build days later.
  • .github/workflows/release.yml runs on a backend/v* tag: it refuses a tag whose commit carries a different PlatformVersion, re-runs the contract tests and the suite on the exact commit being published, warms the Go module proxy, and cuts a GitHub release from the changelog section for that version.
  • .github/CODEOWNERS, one entry, for backend/pkg/nexus.
  • The deploy now stamps PlatformVersion into the image. The Dockerfile has accepted a VERSION build argument since it was written and nothing ever passed one, so every production image has told every app store it is 1.0.0 — which is what a manifest's "platform": ">=1.1.0" constraint would have been checked against.

Added — pkg/nexus, the SDK that makes a product possible without a fork

Step 1 of the ecosystem split (docs/ECOSYSTEM_GIT_STRATEGY.md §6), and the precondition for every step after it. The module contract lived in backend/internal/module.go, and Go forbids another repository from importing anything under internal/ — so the only way to build a product on this platform was to fork it, and one fork per product means every fix is applied once per product for ever.

  • New public package backend/pkg/nexus carrying the contract: Module, Dependency, PermissionDefinition, MenuDefinition, and the compile-time registry (Register, Get, List, VerifyModuleExists). It imports nothing from internal/, and a test enforces that by walking its import graph — an import that crept in would compile fine here and break every distribution.
  • All fourteen modules and the platform now import it, which is the only way to know the SDK is usable: an SDK its author does not use is one nobody has tried.
  • internal/module.go and internal/platform/appregistry are gone. Not deprecated — both were under internal/, so no caller outside this repository could exist to break, and leaving forwarding shims would have left two names for one thing.
  • A test defines a module in an external test package against pkg/nexus alone, registers it and mounts its routes: the same view a distribution repository has of this platform.

Implementations stay in internal/. The SDK is a contract, and a contract that also carried the machinery would drag the machinery into the semver promise.

The service half followed, and with it §6 step 1 is complete: a module in another repository can now be written, not merely declared.

  • JSON / Error; the tenant and caller context (RequireTenant, TenantID, UserClaims, UserFromContext, and the setters the session middleware uses); a DB handle; PermissionStore with RequirePermission; Audit and the AuditSink the platform installs at startup.
  • Platform, handed to every module's constructor. All fourteen took a *pgxpool.Pool and six built their own permission store with an internal constructor no external module could call. They now take one argument — New(p nexus.Platform) — which is also what stops the signature changing every time the platform lends modules something new.
  • The permission store is now one per process rather than one per module. It caches grants per tenant and is invalidated across replicas; fourteen of them meant fourteen caches of the same rows.
  • httpx, tenant, auth and rbac forward to the SDK rather than duplicating it. For the context packages that is not tidiness but correctness: two packages each holding their own context key would write and read different values, and the second would always be empty.
  • DB carries BeginTx so a module can open a read-only transaction — the report engine opens every run that way. It does not carry Acquire: pinning a connection is a platform capability, and offering it to every module would offer every module the ability to exhaust the pool. The one place that needs it asks by type assertion, in internal/platform/reporting.

The surface was measured rather than designed. Across the fourteen modules the whole demand on the platform was: write a JSON response (420 call sites), name the organisation and the caller (94), refuse on a missing permission (24), record what happened (41), and query the database. The report engine (40 symbols), the catalogue, the state rails and the SSO provider are each a subsystem or a specialised rail, and none belongs in the first version of a contract that cannot be narrowed later. settings, flags and emailverify are in docs/ECOSYSTEM_GIT_STRATEGY.md §2.1's sketch of this package and are not here, because no module imports them.

Added — egov, the front door to the state's systems

The last of the three naming corrections, and the only one that creates a module rather than renaming one. The pieces existed and were scattered: the ХУР registry lookups were two handlers in the platform's own route table, whether the eID, ДАН and ХУР rails were even configured was knowable only from the deployment's environment, and what had been looked up sat in the audit log with nothing pointing at it.

  • New app io.gerege.nexus.egov — "Цахим засгийн холболт" / "e-Government Link", three screens under /egov: registry lookups, the state of the three rails, and the history of what this organisation asked. In DefaultApps, so every existing tenant gets it on the next boot, and removable like any other.
  • POST /api/v1/xyp/citizen and /company moved to /api/v1/egov/* and are now behind the app gate. They were platform routes any tenant could reach with the permission; a tenant that removes this app now loses them, at both addresses.
  • Permissions xyp.citizen.read / xyp.company.readegov.citizen.read / egov.company.read (migration 00057, with a down, renamed in place so every grant survives), plus a new egov.read for the screens.
  • PermissionDefinition gained AdminOnly. The installer decided who gets a permission by looking at the end of its code — anything ending .read went to every member of the organisation — and the two registry lookups are a .read by grammar and an administrative act by consequence. Without this the rename would have silently handed "look up any citizen by registration number" to every employee, since migration 00024 had granted the old codes to the administrator role alone.
  • Contacts degrades instead of depending. Its registry auto-fill now calls the e-Government endpoint and the button is not offered when the app is absent, rather than being offered and answering 403. egov exports a Registry interface for in-process callers; contacts is deliberately not a dependent of the app.
  • A first test of the app gate itself (app_gate_test.go). It had none: every module is mounted behind appGateMiddleware and nothing asserted what that does. Writing it found a nil-pointer dereference in NewServer that would have panicked every deployment at startup.

What deliberately did not move into the app: the eID and ДАН sign-in flows, which run before anybody is signed in, and a person's own list of linked identities with the button that unlinks one. The second is the same reasoning profile_handlers.go has carried since before this module existed — an app is installed per organisation and an administrator can remove one, and somebody's ability to detach their own national identity is not their employer's to take away. The connections screen links to /profile rather than owning it.

Deprecated — to be removed in the next release

  • POST /api/v1/xyp/citizen and POST /api/v1/xyp/company, now mounted by the app alongside the /api/v1/egov/* pair.

Changed — developer_portal becomes sso_clients

The second of the three naming corrections. developer_portal named the wrong thing twice: there is a real developer portal in this ecosystem — developer.gerege.mn, backed by apps/publisher_studio, where a third party submits an app to the store — and an administrator looking for that and landing here had nothing in the name to tell them they were in the wrong product. What this app actually is has no developers in it: CRUD over the OAuth2 clients registered against this platform's own OIDC provider, run by whoever looks after an organisation's integrations.

  • io.gerege.nexus.developer_portalio.gerege.nexus.sso_clients, slug developer_portalsso-clients, package internal/apps/developer_portalinternal/apps/sso_clients, type DeveloperPortalModuleSSOClientsModule. Name "Developer Portal & OAuth2 SSO" → "SSO Clients" / "SSO клиентүүд".
  • Permissions developer.read / developer.managesso_clients.read / sso_clients.manage.
  • Routes /api/v1/developer/*/api/v1/sso-clients/*. Screens moved with them: /developer/apps/sso-clients, and the blueprint screens from /module/developer/* to /module/sso-clients/*.
  • The developer.* i18n namespace became sso_clients.* across the base dictionary and all five generated overlays.
  • Migration 00056_sso_clients_rename.sql, with a down. It touches nothing in oauth2_clients: the clients this app manages are keyed by their own client_id and never carried the app's id, so renaming the screen renames nobody's integration.

Deprecated — to be removed in the next release

  • io.gerege.nexus.developer_portal as a catalogue id and developer_portal as a catalogue slug, both resolved by appcatalog/alias.go.
  • sso_clients.LegacyID.
  • The /api/v1/developer/* route tree, still mounted alongside /api/v1/sso-clients/*. A dual mount rather than the redirect used for the organisation rename: nothing moved between the platform and the app here, so both trees are the same handlers behind the same gate.

Changed — core becomes organisation, and stops being undeletable

The first of three naming corrections made before the platform is published as an SDK, where a name becomes part of an import path and stops being cheap to change. core was the name of the app holding departments and people and the name of the platform underneath every app; one of the two had to give it up, and it is not the platform.

  • io.gerege.nexus.coreio.gerege.nexus.organisation, slug coreorganisation, package internal/apps/coreinternal/apps/organisation. Permissions core.read / core.manageorganisation.read / organisation.manage, and the two reports it registers move with them (core.user_activity, core.headcount_by_unit).
  • The tenant's legal profile is no longer part of an app. The registered name, registration number, address, logo and parent organisation moved to the platform (GET/PUT /api/v1/tenant/profile), and so did a person's own preferences (GET/PUT /api/v1/profile/preferences). The control plane, the XYP rail and the SSO consent screen all read the organisation's registered name without caring which apps a tenant has, and none of that could depend on a screen an administrator is able to remove.
  • Editing the legal profile now requires the tenant administrator role, where it previously accepted core.manage — which the manager role also held. These fields print on documents and are what a state-registry lookup is checked against.
  • The organisation app can be uninstalled. CoreApps became DefaultApps: the list still installs the app for every new tenant, but nothing on the platform refuses to remove it any more, and the sweep that installs it no longer puts back what somebody has taken away. Nothing imports the app and no other module's foreign keys point at a department, so a deployment with no use for an internal directory — a queue kiosk, a single-purpose portal — is no longer made to carry one. Uninstalling closes the gate and drops no rows.
  • Migration 00055_organisation_rename.sql moves the id, the slug, the stored manifests, the permission codes, the report keys and the module kill-switch flag, and has a down. Grants survive: role_permissions joins on the permission id, which does not move.

Deprecated — to be removed in the next release

Each of these is marked // DEPRECATED: remove in vNEXT at its definition.

  • io.gerege.nexus.core as a catalogue id, and core as a catalogue slug. A catalogue published by a registry that has not caught up is rewritten into the new names as it is parsed (appcatalog/alias.go), and /api/v1/store/apps/core/... still resolves.
  • appcatalog.ResolveAppID, appcatalog.ResolveAppSlug and the rename tables behind them.
  • organisation.LegacyID.
  • The whole /api/v1/core/* route tree, which now answers 308 pointing at /api/v1/organisation/*, /api/v1/tenant/profile and /api/v1/profile/preferences.
  • The core field on the installed-apps response is gone already, since no app is undeletable for it to describe.

Added — Control Plane Catalog Management & Migration Deprecation (CP-46)

  • Control Plane Catalog Endpoints (/cp/api/catalog/sync, /cp/api/catalog/status, /cp/api/catalog/overview): Operators can now monitor app catalog status and trigger catalog sync on demand with step-up & audit logging.
  • Store Admin Deprecation: Added Deprecation: true and Link headers to tenant-level store admin endpoints (/admin/store/sync, /admin/store/status) to initiate graceful client migration.
  • Native macOS App Icon: Updated macOS native AppKit app to load logo.jpg dynamically at runtime.
  • Asset Optimization: Compressed login_landing_bg.jpg (~65% reduction, 1.0MB -> 360KB).
  • Code & Doc Audit: Full codebase verification, removing obsolete files and unused imports.

Added — The break-glass account, and the storage limit that refuses

The last two things the design document asked for and the phases had not delivered.

  • Break-glass (§2.4 of the plan): one emergency operator account whose password lives in a safe. It grants nothing extra — the same password and the same authenticator as everybody — and the whole of it is what happens when it is used: an ERROR log line naming who and from where, a metric of its own, a severity: page alert on that metric, and its own action in the operator audit. A locked door with an alarm on it rather than a hidden one. The database permits exactly one such account, because the second one becomes somebody's ordinary login and the alarm starts crying wolf.
  • The storage limit now refuses. CP-2 recorded it and CP-5 measured it; this is the check, at the one place on the platform where a file of any size is kept. It compares against the last nightly measurement rather than summing every blob on each upload — a storage quota is a commercial boundary, and the disk alert is what protects the platform.
  • The metering query for storage now sums esign_documents.byte_size, a column the upload already writes, rather than measuring the blobs themselves.

Added — Counting what each organisation used, from the database rather than from the metrics

CP-5, the last phase of the control plane. usage_events holds one row per organisation per metric per day, written by a job that runs nightly and again during the day, so the console is never showing yesterday's picture to somebody looking at it after lunch.

  • Not from Prometheus, deliberately. The first phase decided that no metric would ever carry a tenant label, because a label whose values are customers is a series count that only grows. The bill for that decision comes due here and is paid in SQL — and arguably at a profit, because what gets counted is acts recorded in the audit trail rather than HTTP requests, which is what a usage line should be based on in the first place.
  • Not an event per request. A row per API call is a table nobody can query by the second month. What is stored is the day's total, and re-running a day's collection rewrites it rather than doubling it.
  • Two of the five metrics are not sums, and nothing sums them. Active people is a peak — adding daily actives over a month counts the same person thirty times — and storage is a reading, where a sum would be a number that never goes down. The screen labels them accordingly.
  • The console reads usage and cannot write it. usage_events is granted to the operator role for SELECT alone, so there is no console request that can change a number a bill might rest on. That is the first question anybody asks of a metering system in a dispute, and the answer should be a grant rather than a promise.
  • The monthly AI limit CP-2 could only record is now enforced against these numbers, in middleware rather than in each of the six AI handlers, answering 429 — the allowance is spent, not the request forbidden. Storage remains measured and shown as not-yet-enforced, because refusing an upload means a check on every upload path and the screen should not imply one that is not there.

Added — A front page that answers "is the platform well", and the platform's first backup

CP-4. The console's home screen is now the deployment's health — requests, errors, latency, the government systems' lights, what is alerting, the disk, which background jobs have quietly stopped, what version is running — and the organisation list moved to its own page. Every panel links into Grafana, and none of it tries to replace Grafana.

  • This platform had no backups. Not a gap in the console: a gap in the platform. deploy/scripts/backup.sh takes a pg_dump, prunes what is older than a fortnight, and — the part that matters — writes the outcome into platform_backups whether it worked or not, because a cron job that fails silently is discovered on the morning somebody needs it. The console shows the last backup, its size, and the date of the last restore test, which is recorded by hand: an untested backup is not a backup, and the only way to know one has been tested is that somebody says so.
  • The deploy button asks GitHub, and can do nothing else. It dispatches the workflow this repository already has, with a fine-grained token scoped to that workflow, behind a superadmin capability and a second factor — and it hands back the link to the run rather than polling somebody else's API for ten minutes. No shell, no docker exec, no environment editing: the plan lists all three among the things this console deliberately does not have.
  • Per-organisation error rates come from the audit trail, not from Prometheus, because the very first phase decided that no metric would ever carry a tenant label. That decision has a price and this is it — paid deliberately, and arguably at a profit, since what an operator wants to know is whose work is failing.
  • Every panel degrades on its own. A Prometheus that is down leaves the alerts, the jobs, the backups and the versions intact, and a deployment with no monitoring stack at all gets a page that says so rather than a page of zeroes that reads as "everything is fine". NaN — what PromQL returns when it divides by nothing — is read as zero rather than reaching a screen.

Added — A platform that can be told to behave differently, without a deploy

CP-3, and the setting it exists for: this platform is private by default. Until now, whether a stranger who could authenticate somewhere else became a user here was decided by which environment variables happened to be set — EID_JIT_TENANT_SLUG in one file, SSO_CLIENT_TENANT in another, read by two packages, with no single place that answered "can somebody get in".

  • platform.access_mode. One check, at the one thing every provisioning path does — create an account — and it is closed unless somebody says otherwise. Signing in as an existing user is untouched; so is an invitation, because being invited is the registration. The sign-in screen reads the mode and explains itself rather than letting people discover it by failing, and switching to public takes effect on the next request with no restart.
  • Settings that cannot hold a secret. Every key is declared in Go with a kind, a default, a validation and the environment variable it still falls back to. There is no secret kind — not "should not be used", does not exist — and Register panics on a key that reads like one, so a credential cannot reach a table an operator can edit. A row whose key is not declared is ignored, so writing to the table is not a way to introduce behaviour.
  • Every change has a reason, a history and one button back. The rollback is itself a change: it writes a new history row rather than removing the one it undoes, because a history that can be rewound is a history somebody can edit. Values reach the running platform through a thirty-second refresh and the invalidation bus, so a change is felt everywhere at once where Redis is present and within half a minute where it is not.
  • Feature flags with an expiry that is a reminder, not a switch. Kinds (release, kill switch, experiment), per-organisation overrides, and a percentage rollout keyed on a stable hash — an organisation inside 10% is still inside 50%, and two flags at the same percentage select different organisations. Flags that have outlived their date keep working and appear as a warning on the configuration screen, which is the only thing that ever clears flag debt. A module's kill switch is a naming convention rather than new machinery: module.<app id>.disabled.
  • Maintenance and announcements. Read-only for the platform or for one organisation, refusing writes with 503 while leaving every read and the way out working. Announcements carry their own expiry and arrive with /me, so the shell shows a banner without a second thing to poll.
  • The first four settings — the access mode, the session idle timeout, the catalogue's sync interval and the AI model — are read where they are used rather than captured at startup, so changing them means changing them.

Fixed

  • Creating an organisation from the console could not have worked on a deployment with the row-level grants applied: the trigger that seeds a tenant's roles writes role_permissions, and the first administrator's account needs INSERT on users, neither of which the console's role held. Both were found by CP-3's integration tests rather than by a customer, which is the argument for narrow grants: a forgotten one fails loudly.

Added — Operating an organisation from the console, without being able to take it

CP-2 gives the console the buttons CP-1 deliberately withheld: creating an organisation, closing one, deleting one, helping somebody back into their account, and — with a reason and a banner — looking at the platform as they see it. Everything in it is shaped by one rule: an operator should be able to do their job without being able to do quiet damage.

  • Nothing is sudden. Suspension is reversible and ends every live session in the same transaction. Deletion is not a button: one superadmin asks, a different one agrees — the database refuses a self-approval with a CHECK constraint, not just the Go code — and only then does a thirty-day countdown start, cancellable throughout. The console holds no DELETE privilege on any table; a sweep on the platform path removes the rows when the time comes.
  • The console cannot change a password. It sends a link. Migration 00050 grants it UPDATE on exactly two columns of users — the lockout counter and its expiry — so a support handler that tried to write a password hash is refused by PostgreSQL. The platform had no password-reset flow at all before this; invitations and resets now share one single-use, 24-hour token, sent through the mail rail the platform already had.
  • Impersonation, made impossible to do quietly. A typed reason, the second factor again, thirty minutes, an amber banner the session itself drives, and two audit trails — ours and the organisation's own, which their administrators can read. A single-use sixty-second handover carries it across the hostname boundary, because a cookie cannot. A suspended organisation cannot be entered this way.
  • Limits. tenant_quotas records people, storage and AI calls per organisation, soft (warns) or hard (refuses). Only the user count is enforced today, because it is the only one this platform can count; the screen says so rather than implying otherwise, and CP-5's metering is what switches the other two on.
  • Creating an organisation installs its apps through the platform's own installer — the same path the store uses, with its dependency resolution — and reports which ones did not land instead of throwing the organisation away. Its first administrator gets an invitation, never a password an operator chose.
  • The four operator roles are not a ladder: operator can open an organisation and cannot look inside one; support is the other way round. The whole authorization model is one map in operator.go.

Added — A console for operating the platform, kept away from the platform

Somebody has to be able to see which organisations exist, which apps they run and what has been done to them — and until now that somebody used psql. This is the first phase of the operator console described in docs/CONTROL_PLANE_PLAN.md: the foundation, on which suspension, support and configuration are built next. Guide in docs/CONTROL_PLANE.md.

  • One binary, nothing else shared. The console is /cp/api on the same Go process and /cp on the same Next.js build, because a second service would double the deployment and the monitoring for a console two people use. It shares nothing else: its own hostname, accounts, sessions, cookie, database role and audit table. A tenant administrator's account being taken reaches none of it.
  • Three layers to reach it, none trusting another. nginx serves cp.nexus.gerege.mn behind an address allowlist that ships denying everyone; the API and the frontend both answer 404 — not 403, which would confirm something is there — to a request on any other hostname; and sign-in needs a password and an authenticator code. CONTROL_PLANE_HOST unset in production means the console does not exist, which is the safe reading of a variable nobody set.
  • The console cannot write. Migration 00049 gives it a database role of its own with SELECT on ten named tables and read-only policies to match — so "the operator sees every organisation" is a list of permissions rather than a switch that turns row-level security off. A table nobody granted is a table the console cannot read, and a new one does not become visible by existing.
  • A write that was not recorded did not happen. Every console write goes through one function that puts the change and its operator_audit row in the same transaction, and the middleware above it withholds the response — headers, cookies and all — from any write that answered successfully without an audit row. The table refuses UPDATE and DELETE at the database, by trigger, including from the role that owns it.
  • A code works once. The time step of every accepted TOTP is stored and must strictly increase, so a code read over somebody's shoulder within its thirty seconds is already spent. Verified against RFC 6238's own test vectors.
  • No sign-up screen, ever. The first operator is created by operator-bootstrap on the host, by somebody who already holds the database credentials — no default password, no first-run page, no environment variable left behind. The account cannot sign in until a code from its authenticator is confirmed, so an interrupted setup leaves a locked door rather than a password-only one.
  • Sessions last 8 hours and end after 30 minutes idle, against the platform's 12 and 90; step-up re-confirms the second factor for five minutes before a dangerous action, and is mounted on nothing yet because nothing dangerous exists yet.
  • cp_login_attempts_total{result} counts sign-in attempts by outcome, apart from logins_total: platform sign-ins fail all day because people mistype passwords, while a dozen failures against the console in an hour is somebody trying.

Fixed

  • The CSRF middleware defended the tenant session cookie and would not have defended the console's, which was introduced in the same change. Both are named in one place now, so the next cookie-authenticated surface is a line there rather than a hole.

Added — One organisation seeing another's report, with their permission

A coal mine contracts a hundred transport companies. Each keeps its trips in its own tenant; the mine wants one consolidated "Transport" report. That request runs against everything this platform is built to prevent, so the answer is not to weaken the isolation but to add a separate, permissioned path beside it. §3.5 of the design; guide in docs/REPORT_SHARING.md.

  • Nothing crosses a tenant boundary. A consolidated run calls the ordinary report once per grantor, inside that grantor's own tenant context. No policy is relaxed, no clause is rewritten, no query reads across organisations, and the report cannot tell it is being consolidated except by the counterparty reference it is handed.
  • Default deny, in three places. No grant, no rows. A report must implement Shareable to be nameable in a grant at all — a report written assuming one organisation may aggregate in ways its rows do not. A request is not a permission: accepted_at is null until the owning organisation's administrator answers, and the query that reads grants ignores anything unaccepted, revoked or expired.
  • The scope is the agreement. counterparty shows the mine the work done for the mine; full is the hierarchical case, a parent consolidating a subsidiary. A report that cannot filter by counterparty cannot be granted that scope — refused, rather than quietly widening to everything.
  • A two-sided row-level policy, which is why report_grants deliberately has no tenant_id column: the general policy from 00029 would have attached itself and hidden each party's own agreements from the other, leaving the receiving side unable to see what it had been given. Accepting is still the owner's alone — the grantor_tenant_id = $2 clause on that one statement is what stops a grantee accepting their own request.
  • Both sides are audited, every run. The reader records that it read; the owner records that it was read, and can open "who has read our data" and see the organisation, the report, the moment and the row count. A transport company will only agree to this if it can check afterwards.
  • Revoking is immediate and grants are never deleted. DELETE is not granted on the table: "who could see our data, and when" is a question asked after the fact.
  • One grantor failing does not fail the run. A hundred organisations is a hundred chances of one slow query, and the ninety-seventh timing out must not produce nothing. That company is named in the result's notes instead — a total quietly missing a company is worse than one that says so.
  • Neither shipped billing report offers counterparty scope, and that is the schema rather than a decision: billing_invoices records a contact name, not a registration number, and matching organisations by typed-in name is the mistake §3.5 avoids by keying grants on a registration number. Both offer full. The mechanism is complete and proven against a live database by the four tests §3.5 asks for, plus three more.

Added — Reports, as a platform layer rather than a screen

io.gerege.nexus.reports. Every app that keeps data now has reports; the module serving them knows about none of them. Guide in docs/REPORTS.md.

  • A report is a declaration. A module implements a Go interface saying what it is called in seven languages, what parameters it accepts, what columns it produces and how to produce them. The listing, the parameter form, the table, the chart, the Excel and CSV export, the schedule and the audit entry are written once and apply to every report anybody ever adds. Adding one is a file in a module and a line in its constructor — no handler, no frontend change.
  • Eight reports to start with: revenue by month and invoice status (billing); stock on hand and movement summary (inventory); signatures by rail and signer activity (e-signature); user activity and headcount by unit (core). The e-signature one separates the rails and marks which is qualified, because only the eID rail produces a qualified signature in Mongolian law and a report that counted both together would answer the wrong question.
  • The tenant boundary is the database's, not the query author's. A report runs inside the caller's tenant binding, in a read-only transaction, under a thirty-second statement_timeout — the SQL one, not a context deadline, because a cancelled context stops this process waiting and does not stop PostgreSQL working. A report that forgets its WHERE tenant_id returns nothing rather than everyone's rows, and there is an integration test against a real database that proves it.
  • Three gates, not one. A report belonging to an app the organisation has not installed is absent from the listing, refused by key with a 404, and refused again when a schedule names it. Filtering the list is not enough: the API is a separate door.
  • Every run and every export is audited, separately. An export is a copy of the organisation's numbers leaving the platform, which is a different act from reading them on screen — and §3.5 of the design requires both before one tenant may see anything of another's.
  • Scheduled reports, with no second process. A cron expression, a minute-ticker goroutine in the API, and a PostgreSQL advisory lock. Due rows are claimed before the report is produced, not after it is sent: a replica restarting between "sent" and "recorded" would send the report twice, and a second copy is indistinguishable from the first while its numbers may differ.
  • Delivery is SMTP, and the design document said otherwise. It called for the hosted verification service; that service sends one thing, a verification link, and has no endpoint for a subject, a body or an attachment. With REPORT_SMTP_URL unset a due schedule is still produced and still recorded, with "delivery not configured" as its outcome and a warning on the screen — which is a different and more useful state than not running.
  • Exports that can be used. xlsx via excelize with real numeric cells, a totals row and a frozen header; CSV with a UTF-8 BOM, without which Excel on Windows renders every Mongolian heading as mojibake — the whole content of the file.

Added — Traces, and errors that group themselves

The third pillar and the tool beside it, both env-gated and both off by default. Guides in docs/MONITORING.md §11 and §12.

  • SetupTracing now sets up tracing. It was a stub that logged "opentelemetry tracing initialized" and initialized nothing — worse than no tracing, because an operator reading the startup log had every reason to believe traces existed somewhere. It is now the OTLP exporter, a batch processor, and a ParentBased(TraceIDRatioBased) sampler at 10%.
  • Off means off. With OTEL_EXPORTER_OTLP_ENDPOINT unset there is no exporter, no batch processor, no background goroutine and no sampling decision: every span the code starts is a no-op. That is the condition for putting tracing in the default path rather than behind a build tag.
  • otelhttp on the router and otelpgx on the pool, so a slow request resolves into the queries it waited on. Spans are named by chi's route pattern, never the URL — a span per document id is unbounded, the same cardinality argument the metrics middleware already makes. /health, /ready and /metrics are excluded: Docker and Prometheus call them every few seconds, and at any sampling rate they would be most of what is stored.
  • Query parameters are never recorded, which is otelpgx's default and is now a comment saying it must stay that way. The arguments are the row a query is about — an address, a national identifier, a password hash on the way in — and a span is readable by anyone who can open Grafana. Verified against a live Tempo: db.query.text comes through as placeholders.
  • Logs join traces. Every slog line written inside a span carries trace_id and span_id, and Grafana's Loki datasource turns the first into a link. Deliberately not the otelslog bridge: that ships logs over OTLP, a second delivery path for something Alloy already carries to Loki. Tempo's datasource links back the other way, to the container's logs and to the RED metrics for the service.
  • Tempo in the monitoring stack — 72h retention, filesystem storage, no index over span contents. A trace is found by id from a log line, or through the span metrics and service graph Tempo generates into Prometheus.
  • GlitchTip as deploy/docker-compose.glitchtip.yml, its own stack with its own Postgres. Separate rather than a compose profile because compose resolves ${VAR:?} for every service in a file whether or not its profile is active, so a required secret there would have stopped the metrics stack from starting on a deployment that never wanted error tracking.
  • Panics are reported, with a scrubber that fails closed. chi's Recoverer printed a stack trace to stdout and nothing else; the replacement logs it with the request id, the route and the tenant, and sends an event when SENTRY_DSN is set. What never leaves: the query string (where single-use references live), cookies, the Authorization header, the request body, and the person — e-mail, name and IP are dropped, the tenant id stays so "how many organisations does this affect" still has an answer. Headers are an allow-list, so one added by a future proxy is dropped rather than forwarded. The frontend half is @sentry/nextjs with the same rules and no Session Replay: it records the DOM of what the person was looking at, which here is a registration number or a document awaiting signature.

Added — A monitoring stack that reads the platform

deploy/docker-compose.monitoring.yml: Prometheus, Alertmanager, Loki, Alloy, Grafana, node_exporter, cAdvisor, postgres_exporter, redis_exporter. Guides in docs/MONITORING.md and, for every alert, docs/RUNBOOKS.md.

  • A separate compose file, brought up as a separate project. Nothing in docker-compose.prod.yml depends on anything in it, no service in it is in a request path, and taking it down is a safe thing to do at any hour. It reaches the platform by joining the platform's own Docker network as an external one, so Prometheus scrapes gerege_nexus_backend:8080 directly rather than through a published port.
  • Alerting on the error budget, not on a threshold. The Google SRE Workbook's multi-window multi-burn-rate pattern against a 99.9% objective: 14.4× over 1h+5m and 6× over 6h+30m page, 1× over 3d+6h opens a ticket. Both windows must be over the rate, which is what stops a spike that has already ended from waking anybody and what lets the alert clear itself. Every external-system rule is additionally guarded by a traffic condition — without one, a system called twice at 04:00 with one failure is a 50% error rate.
  • Runbooks are part of the alert. Each rule carries a runbook annotation pointing at its section, and each section says what happened, what to check in the first five minutes, how to fix it and when to escalate. An alert nobody knows what to do about is an alert that gets silenced.
  • Dashboards as code, with allowUiUpdates: false. Four of them: API overview (RED plus remaining error budget), external systems, infrastructure, and resilience/volume. A panel fixed at 02:00 during an incident is worth keeping, and the way to keep it is a commit rather than a row in a volume.
  • monitoring database role (migration 00044) holding pg_monitor and nothing else — no table, no tenant row. Created without a password, because a migration is a file in this repository; the operator sets one once, and docs/MONITORING.md §2.2 is the command.
  • No secret in the repository. Grafana's password is required with no default. Alertmanager's receivers are rendered at container start from the environment, so leaving SMTP or Telegram empty genuinely disables that channel instead of producing a config Alertmanager refuses to load — and a stack that will not start because nobody has a mail server is a stack nobody installs.
  • Logs, without turning Loki into Elasticsearch. Alloy reads the Docker socket read-only and attaches container, service, level and deployment. request_id and tenant_id are deliberately not labels: each distinct value would be its own stream. They stay in the line, where | json | request_id=… finds them at query time.
  • Uptime Kuma is documented and deliberately not deployed here. A monitor on the server it monitors goes down with it. docs/MONITORING.md §9 has the instructions for running it somewhere else.

Added — The platform can now be measured

/metrics carried two series: a request count and a request duration. That is the R and the D of RED and nothing else — no saturation, no business volume, no sign that a call to ХУР or eID had gone slow, and no way to tell a breach of the in-flight ceiling from any other 503. Everything a dashboard would need was missing before the dashboards were, which is why this lands before the stack that reads it (design: docs/MONITORING_AND_REPORTING_PROPOSAL.md).

  • Saturation. The Go runtime and process collectors are asserted rather than assumed — client_golang registers both, and a test now fails if that ever stops being true. The pgx pool is exported as a collector read at scrape time: connections acquired, idle and total, the ceiling they are measured against, and the counters for acquisitions that had to wait or were abandoned.
  • Outbound calls. One histogram, external_request_duration_seconds{system,operation,status}, across ХУР, eID, ДАН, the eSign HSM, Gemini and the address-verification service. It wraps the call rather than the transport, because three of those six are reached through clients whose http.Client is private to open-gerege-core. system is a closed list and an unrecognised name folds into other, so a call site added without a constant cannot widen the label set.
  • Business volume. logins_total{method,result}, invoices_created_total, documents_signed_total{rail,result} and ai_requests_total{kind}, each incremented at the one place every path through it converges — failGoogle for one, store.markSigned for both e-signature rails. No tenant appears in any label: that breakdown is a reporting question, answered against rows that can be deleted rather than series that cannot.
  • The load shedder is visible. resilience_load_shed_total and resilience_in_flight_requests. There is deliberately no breaker gauge: the adaptive breaker the design document assumed was removed from platform/resilience before this work began, and a gauge pinned at zero would render a panel claiming every breaker is closed on a platform that has none.
  • Logs carry the request. Every slog line written while serving a request now carries request_id and tenant_id, read from the context by a handler wrapper rather than passed by hand through several hundred call sites. chi's colour access logger is gone with it — it wrote an unparseable second format into the middle of a JSON stream, named no request, and printed the raw path, which for /api/v1/verify/{ref} meant logging a single-use credential.
  • Audit events are kept. New audit_events table (migration 00043) with the 00029 tenant policy, written alongside the existing log line by audit.Record — same signature, so none of its sixty-eight call sites moved. The write is best effort and bounded at one second: an audit row failing must never fail the act it is recording, and the log line has already been written by then. user_id is text and unconstrained, because the trail has to outlive a deleted user and because the device handlers record device:<id> for an act nobody signed in for.

Added — Signing in with Google

A "Google-ээр нэвтрэх" button beside eID on the platform's own sign-in screen, off unless GOOGLE_LOGIN_CLIENT_ID is set. It is an addition, not the federation added a moment ago: SSO_CLIENT_ISSUER closes this deployment's own sign-in paths and hands the question of who somebody is to a provider, while this is one more of its own answers and closes nothing. On a deployment that does federate, the button is withdrawn along with the rest — a front door nobody manages is exactly what federating was meant to remove.

Google is an ordinary OpenID Connect provider, so there is no second implementation: the same discovery, PKCE, code exchange and RS256 id_token verification serve both, and both land on the same (issuer, subject) account resolution. What is written separately is only what differs — which cookie the flow parks in, and who is allowed through.

  • The credentials are deliberately not the connectors'. Drive and Meet already use GOOGLE_OAUTH_CLIENT_ID; the same Google project usually issues both and they may hold the same value, but inheriting a sign-in path from a document connector would mean enabling the connector quietly opened a new front door.
  • Three filters, in order. An unverified address is refused, because the address is what an existing local account is matched on and an unverified one would let anybody who can type into a Google profile claim somebody else's account. Then GOOGLE_LOGIN_ALLOWED_DOMAINS, when set. Then the account itself: with no GOOGLE_LOGIN_TENANT nobody is provisioned, so a Google identity only ever reaches an account that already exists here.
  • No id_token is kept. That cookie exists so signing out can end the session at a provider this deployment federates to; ending somebody's Google session because they signed out of this platform is not this platform's business.

Added — A deployment can now be an SSO client, not only a provider

The platform has always been an OpenID Connect provider: it could hand identities out and never take one in, so a group running several deployments had one sign-in per deployment and no way to make one of them the source of truth. This is the other half. Setting SSO_CLIENT_ISSUER makes a deployment a relying party of the provider named there — including of another Gerege Nexus — and the two halves are independent: an instance can be a provider, a client, or both, which is what a regional deployment federating upward while still issuing identities to its own installed apps needs. Full guide in docs/SSO_FEDERATION.md.

  • ssoclient, the relying-party protocol. Discovery with the issuer check that makes every advertised endpoint trustworthy, a JWKS cache that refetches on an unknown kid, authorization with mandatory PKCE, the code exchange, and id_token verification that is deliberately narrow: RS256 only — none and the HMAC family are what alg confusion is made of — with iss, aud, azp, exp, iat and nonce all checked before a claim is believed. The pending sign-in lives in a short-lived HttpOnly cookie rather than a table, because a row would be written for every click of a sign-in button including every crawler's.
  • Client mode closes the local front door. With a provider named, this deployment's password, eID and DAN sign-in endpoints stop answering and say where sign-in actually happens; the login screen becomes a hand-off. A deployment that federates its identity and also keeps its own password login has not federated anything — it has two front doors and one of them is unmanaged. SSO_CLIENT_LOCAL_LOGIN=true keeps them, as the documented way back in when the provider is the thing that is broken.
  • Signing out signs you out at the provider. /auth/logout now answers with an end_session_url on a federated deployment, and the browser follows it: the provider ends its own session and returns the person to this deployment's registered post-logout address. Without that step, "sign out" followed by "sign in" walks straight back into the still-live session upstream.
  • Accounts are keyed on (issuer, subject), never on the email address. An address is a label a provider can change, and treating one as an identity means whoever is given a departed colleague's address inherits their account. A local account with a matching verified address is adopted on first federated sign-in, which is what makes federating a running deployment possible; SSO_CLIENT_TENANT decides whether a stranger the provider vouches for is provisioned at all, and unset means refused.

Added — RP-initiated logout at the provider (/oauth2/logout)

The discovery document has advertised end_session_endpoint since it was written, and nothing served it: a relying party that ended its own session and sent the person here — which is what a conformant client does — landed on a 404 while staying signed in. That is worse than not advertising it, because the next click on "sign in" looks like the logout was ignored.

  • post_logout_redirect_uris is a new column on oauth2_clients (migration 00041), editable from the developer portal and matched exactly. It is not redirect_uris reused: a sign-in callback is a machine-read path that receives a code, a post-logout address is a page a person looks at, and one list would widen both whenever either was extended. An unregistered return address is refused rather than followed — a logout URL is one a client hands out freely, so following one unchecked would make the provider an open redirector.
  • The client is resolved from client_id or from a verified id_token_hint. An unverifiable hint is ignored rather than refused: by the time it is read the person is already signed out, and failing there would strand them.

Fixed — Two defects the new tests turned up

  • A client registered with no post-logout addresses failed to insert. A nil Go slice is sent as SQL NULL, and every array column on oauth2_clients is NOT NULL with an empty-array default — a default that only applies when the column is left out of the statement, and these are listed explicitly. Every array is now normalised at the store boundary.
  • HTTP Basic client credentials were not URL-decoded. RFC 6749 §2.3.1 has a client form-urlencode both halves before base64ing them, so a conformant client's secret arrived escaped and was compared, still escaped, against what was registered. It never bit in practice because the secrets this provider mints are hex, but it would have bitten the first integrator who chose their own. A value that does not decode is used as it stands, so a client that skipped the encoding is not refused over a disagreement about transport.

Without this, single sign-on is not single. A relying party signing somebody in sends the browser to /oauth2/auth, which is a top-level navigation arriving from another site, and a Strict cookie is not sent on one — so the authorization endpoint saw no session and showed a login screen to somebody who had signed in a minute earlier. It costs nothing in CSRF terms, because the cookie was never the defence: Lax adds exactly one thing over Strict, a cross-site top-level GET, and every state-changing request goes through security.CSRFMiddleware, which demands positive evidence that a page of ours made it.

Fixed — A lockout that never let go, and three silent truncations

  • A lapsed login lockout re-locked the account on the next single failure. Five bad passwords lock an account for fifteen minutes, but the counter that decides that was only ever reset by a successful sign-in. Once it had reached five it stayed there, so after the window passed the next mistyped password met the threshold on its own and locked the account for another full fifteen minutes — indefinitely. Two consequences: nobody who had been locked out once could afford to typo again, and anybody who knew an address could hold it shut with one request every quarter of an hour. The count now restarts when the lock it produced has expired, and the lapsed lock is cleared by the same statement rather than left asserting a lockout that is over. Reaching five again still locks, so this is a restart and not a way out. The statement moved to a named constant behind recordLoginFailure; all of the behaviour is in the SQL, so the three tests that come with it need a real schema (AUTH_TEST_DATABASE_URL), and CI fails if they skip.
  • A tenant's menu could lose apps it had installed. GetEnabledAppIDsForTenant discarded the per-row scan error and never checked the stream error, so a read that broke partway reached the caller as a short list with a nil error — and a broken stream leaves rows.Next() returning false exactly as a clean end does. That list is what the menu is built from, so the apps that fell off it read as ones the organisation had never installed. Its neighbour GetInstallationsForTenant already did this correctly.
  • The AI copilot could state a truncated search as fact. Its product and knowledge tools dropped the same two errors, and there the truncation becomes a sentence: the model presents whatever it is handed, so half a result set is "you do not stock that" rather than an error the person can retry.

Removed — Code that had stopped being reachable

  • oauthError.Error made the type satisfy error, but it is a carrier — the code and description are rendered into an RFC 6749 §5.2 body and it is never wrapped or unwrapped — so the method was unreachable.
  • issueTokenSet kept the token SaveToken hands back only to discard it with _ = stored; SaveToken returns the same pointer it was given.
  • The integrations screen still rendered an error paragraph from a state nothing had set since the page moved to the banner, and the warehouses screen imported useMemo without using it.

Changed — The platform's apps stop calling themselves examples

io.example.* was placeholder vocabulary from the first week — the reverse domain of nobody, borrowed the way example.com is borrowed — and it had been the primary key of every app in the store ever since. These are Gerege Nexus's own apps and they now say so: io.gerege.nexus.*.

  • A rename of a primary key is a data migration, not a search and replace. 00035 moves apps, app_installations, app_versions and app_dependencies, and rewrites the id inside each stored manifest — the copy an upgrade compares against to decide whether a new version asks for more than the installed one. Both foreign keys are ON UPDATE NO ACTION, so they come off and go back on around the update.
  • The registry carries the matching migration and is deployed first. Between the two deployments an instance can sync a catalogue that already carries the new ids and file them as apps it has never seen; 00035 folds those back into the rows that hold the tenant's history — including an installation somebody made in that window — rather than colliding with them and failing the deployment.
  • The migrations before 00035 are left as they were. They already ran everywhere, and a fresh database is expected to seed the old ids and then arrive here, which is what makes the migration equally true of a database created yesterday and one created next year. The entries above this one keep the ids they shipped with, for the same reason this file keeps the old repository name.
  • mn.example.hrms in the test fixtures stays as it is: it stands in for somebody else's app, and there example is the point.

Added — An organisation to be about

The module Odoo calls base, as a core app: the organisation itself, the people in it, and how it is arranged. The platform had tenants, users and memberships carrying only what signing somebody in needs — a slug, a name, an email. A document that has to print a registration number, an approval that has to name a department, a deadline counted in some timezone: none of those had anywhere to come from, so each app either invented its own or went without.

  • Three screens/organisation (legal identity, address, contact, and the defaults everything else inherits: timezone, locale, currency), /organisation/people (the directory, with job title, department and roles), /organisation/departments (the structure as a tree, with a manager per unit).
  • The split follows Odoo's, because the distinctions it draws are real: res.company → tenants + tenant_profiles, res.users → users, hr.employee → memberships, hr.department → departments. A language preference belongs to a person and follows them between organisations; a job title does not — the same person can be a director in one tenant and a clerk in another.
  • What the schema refuses rather than checks. A department whose parent or manager belongs to another organisation is unrepresentable, not merely rejected: the foreign keys are composite over (id, tenant_id). A tenant without a profile is impossible — a trigger creates one with the tenant, so no reader needs the null check. Both new tables carry the same forced RLS policy as everything else; migration 00029 wrote those once, over the tables that existed then, and a table added later has to say so itself.
  • What the handlers refuse: deactivating yourself, and deactivating the last administrator. Both are support tickets otherwise. Nobody is deleted — a membership is referenced by everything the person did here, so people and departments are deactivated and archived instead.
  • Editing is partial by design. The form sends the fields it touched and the server merges field by field, so correcting a phone number cannot blank a registration number.
  • Being core means two things the store now honours: every tenant has it whether or not anybody installed it, and nobody can disable it. Settings → Apps says so where the Disable button would be, rather than offering one whose only outcome is a refusal.
  • A module with no blueprint no longer goes unlisted. The sidebar was built only for apps named in menu/blueprints.go — the list of screens still to be built — so an app that had built everything it meant to build contributed nothing, including the menus it registers itself. Core walked into exactly that: three working screens and nothing pointing at them.
  • The registry imports the bundled catalogue on every boot rather than only when it is empty. Otherwise a platform app added later reaches nobody: the registry is long past empty, the import is skipped, and every instance polls a catalogue without it.

Added — The App Store moved to appstore.gerege.mn

The catalogue now comes from a registry of its own, and the apps in it can be published by people who do not work here.

  • A registry service (backend/cmd/appstore) serving a signed catalogue every instance pulls: Ed25519 over the raw bytes of the apps array, an ETag so an unchanged catalogue costs a 304, and the document built once per revision and stored as the bytes that were signed — rebuilding per request would hold only for as long as Go's encoder is byte-stable, and the failure when it is not is silent everywhere at once. It shares the platform's appcatalog types with the client that reads it, and a test signs a catalogue the way the endpoint does and feeds it to that client.
  • A storefront (appstore.gerege.mn) that needs no account: server-rendered, seven languages as path segments, real 404s, and no install button — installing happens inside an organisation's own Nexus, so every page says that instead.
  • A publishing console (developer.gerege.mn) where a publisher registers, submits a manifest and watches it through review. The authorization code is exchanged server-side and the identity token lives in an httpOnly cookie, so no token reaches page JavaScript and the platform needs no new CORS origin.
  • Installations follow the catalogue on their own, unless the new version asks for more than the installed one — a widened permission, a widened OAuth scope, or a launch URL that has moved to another host. Those are held at the version they are on, with what they added recorded, and offered to the tenant's administrator as a decision rather than a button.
  • catalog-sign generates the signing pair and signs a catalogue offline, for an air-gapped operator or for testing a client with no registry running.
  • The OIDC endpoints at the root of nexus.gerege.mn are routed to the API. Only /oauth2/token ever was, which was enough for the platform's own screens and for nothing outside it.

Added — Preparing the App Store to live at appstore.gerege.mn

The catalogue is on its way out of this repository and into a registry of its own (docs/APPSTORE_SEPARATION_PLAN.md). Everything here works today in file mode, which stays the default and the whole story for a self-hosted deployment; the registry is opt-in and this platform never depends on it.

  • An installation's version now moves. InstallApp's reinstall branch updated status and enabled and left installed_version alone, so a tenant sat on 1.0.0 for ever while the catalogue carried the app forward — nothing could tell a current installation from a stale one. A version that actually changes is recorded as 'upgraded' with the version it came from, and SyncCatalog finally writes app_versions, the table migration 00002 created and nobody ever filled.
  • Three records of a version are held to each other: the compiled module, the catalogue entry and the manifest. They had drifted — esign shipped 2.0.0 as a module and 1.0.0 in the catalogue, and the developer portal did the same. Both are corrected and the drift is now a startup error. PlatformVersion became a var a release build can stamp with -ldflags, and /health reports it.
  • A tenant can update an app it has already installed. POST /api/v1/store/apps/{slug}/upgrade (admin) re-resolves dependencies, moves the version, records the event and refuses with 409 when there is nothing to move to. The store answers with installed_version, latest_version and update_available, compared as semver rather than as text, and the card carries an Update button beside enable/disable. Migration 00033 adds auto_update and pinned_version.
  • The catalogue can come from a registry (APP_CATALOG_URL): fetched with an ETag, verified against APPSTORE_PUBLIC_KEY before a single field of it is read, cached to disk, and behind all of it the bundled file. Boot never fails because of the registry — an unreachable or lying one costs an instance its updates and a line in the log. CATALOG_SYNC_INTERVAL drives a background refresh; POST /api/v1/admin/store/sync runs one on demand.
  • An app can be a platform that runs somewhere else ("type": "external"). No Go module is required or looked for, permissions come from the manifest, and its menu entry opens in a new tab rather than pretending to be a route here. Its OAuth2 client is gated by installation: a user whose tenant has not installed the app is refused at /oauth2/auth with access_denied, and tokens carry tenant_slug beside tenant_id so the third party knows which organisation it has been handed.

Added — Switching between the organisations you belong to

  • The membership table always allowed several; the runtime allowed one. Which tenant a session acted for was decided at login by whichever membership was oldest (internal/platform/auth_handlers.go), and nothing could change it afterwards — signing out and back in landed the same person in the same tenant, deliberately, so somebody working for two organisations could reach only the first. GET /api/v1/auth/tenants lists the ones they may act for and POST /api/v1/auth/switch-tenant moves the session to one of them.
  • The token is rotated, not the row updated. A session token is the authority to act inside one tenant, and the tenant is what changes; the new session inherits the old one's expiry, so moving between two organisations cannot be used to keep a session alive without signing in again. The membership check lives in the store, where no route can reach the insert without it, and a tenant the caller is not in answers 403 rather than 404 — whether it exists is not their business.
  • Both queries deliberately leave the tenant behind (tenant.Without): memberships carries a tenant_id and is under the row-level policy, so a request bound to the current tenant would answer "which tenants may you act for" with the one the caller is already in.
  • The brand mark is the control, and the account menu carries the same list — the mobile shell hides the header brand below 900px, and a phone is exactly where somebody moves between two organisations. Both render one component over one cached answer, so the two cannot drift and opening the second does not re-ask the server. The mark used to link to /apps, which the Platform tile beneath it in the rail still does. Choosing another organisation reloads the shell rather than patching state: the menus, the permissions and every list on screen belonged to the tenant just left.
  • The demo seed now has two organisations (cmd/api/seed.go): Demo Corporation, with contacts, products, inventory and documents, and Demo Trade LLC, with contacts and billing. One tenant exercises nothing — the switcher, the row-level isolation and the per-tenant permission set all behave identically on a single-tenant deployment and identically wrongly if they are broken. The seeder runs after the platform server is built rather than before it, because an installation row references the apps table that the catalogue sync fills, and it installs through the installer so a demo tenant cannot claim an app whose Go module is not in the binary.

Removed — The Swift macOS client (desktop-mac/)

  • The bundle was a WKWebView pointed at the web client, plus a menu bar, Touch ID and a preferences window for the two server URLs. It was built by a shell script outside CI, so nothing compiled it on a pull request and nothing caught a Swift file that no longer built until somebody ran make build-mac by hand. make build ran it, which meant a build of this repository failed on any machine without Xcode.
  • What it offered over the browser, the browser now offers: the web client is installable as a PWA and gets its own dock icon and window from /manifest.webmanifest, with no download and no store. The README section that documented make build-mac / make run-mac says that instead.
  • The API keeps the path a native client would use — bearer tokens, no ambient cookie — so this is a client leaving, not the platform closing a door. Anyone wanting a native macOS app can build one against the same API in its own repository, where it can have a real build and a real signing identity.

Removed — the Swift macOS shell, in favour of one shell for all platforms

  • desktop-mac/ is gone. It was the reference implementation of the bridge contract and it did its job: the contract exists because that shell was written first and the second one had to meet it. But once the Tauri shell shipped, macOS had two applications doing the same work, and the Tauri one had outgrown it — native sign-in, session restore, and a menu built from the tenant's own menu rather than a hand-written list. Keeping both meant implementing every contract change twice and running two CI workflows to prove the same thing.
  • What is actually lost is the NSToolbar, which Tauri cannot draw. Its contents survive elsewhere: the app shortcuts are in the native menu, search is ⌘/Ctrl+F, reload and preferences are menu items, and the server status moved to the tray icon's tooltip when the Tauri shell was written.
  • make build-mac / make run-mac are now make build-desktop / make run-desktop, and .github/workflows/desktop-mac.yml is removed — the three-platform Tauri workflow already covers what it checked.
  • The entries below that describe desktop-mac are left as written. They record what shipped at the time, which is what a changelog is for.

Fixed — the Tauri shell's bridge was dead on arrival

Found by running the app and signing in — none of it was visible to cargo build, clippy -D warnings, cargo test, or the three-platform CI, all of which stayed green throughout.

  • The work area could not reach the shell at all. Tauri's ACL grants app commands to local pages but nothing to a remote origin, and the capability that was supposed to grant them was never listed in tauri.conf.json, so it was silently ignored. Every bridge call was rejected, the rejection was swallowed by a catch, and the request sat until its 40-second timeout. Both halves are now explicit, and the work area is granted only the three bridge commands — sign-in and preferences stay with the shell's own windows.
  • Defining any permission closed the door on the local windows too: once an ACL exists, every app command is subject to it. The shell's own commands are now listed as well.
  • Every app appeared in the menu bar as "Модуль": the submenu was named after the first row the server returned, and the server returns a pathless group header first. A menu bar wants the application's name.
  • A request made before the work area finished loading hung until it timed out, because the injected script it evaluates into did not exist yet. The script now announces itself, and the bridge waits for that.
  • The shell asked for a password on every launch although the session cookie outlives the process in the webview's store; it now checks /api/v1/auth/me first — and, having restored a session, navigates to the work area instead of leaving the person on the sign-in landing page.
  • The health check was really a port check: it polled /healthz, which this API does not serve, and counted the 404 as healthy. It now polls /health and requires a 2xx.

Added — Tauri v2 desktop shell (desktop-tauri/)

  • A second implementation of one contract, not a second product. The bridge contract (docs/SHELL_CONTRACT.md) is the specification; desktop-mac/ is its Swift reference and this is the cross-platform one. Both inject the same window.GeregeShell, so the web app cannot tell them apart — it hides its own chrome and renders as a work area either way, and in a browser neither exists and nothing changes.
  • platform comes from the build target (macos, windows, linux) and reaches the styling as <html data-shell>. Declared capabilities are notify, badge, external.open, print.system, fs.save, menu.native.
  • Native sign-in window with email/password and both eID flows. The polling loop is Rust (auth.rs) and carries the same reasoning as EIDLogin.tsx: one check in flight at a time, a 400 ms gap between them because the server already holds each request for 25 s, three tolerated failures because a dropped long-poll is ordinary on a mobile network, and a 15-minute backstop that is a stop condition rather than a deadline. The QR is rendered to SVG in Rust so the window depends on no JavaScript library.
  • The session cookie forced the transport. session_token is HttpOnly and belongs to the API origin, the web app authenticates with credentials: "include" and no bearer header, and neither Tauri nor wry can write a cookie into a webview from outside. The only way it lands in the right jar is for that webview to receive the Set-Cookie itself, so the sign-in requests are issued there (bridge.rs) while the flow logic stays in Rust. The work-area window is created hidden and stays hidden until sign-in completes.
  • The native menu is the tenant's menu. GET /api/v1/menus with the Accept-Language the person chose, grouped per app; menu.changed rebuilds it; choosing an item emits shell:navigate so the work area routes without a full reload. macOS maps a small set of icon names to native symbols and leaves the rest bare, which is steadier than half-matching them.
  • Server health lives on the tray icon, checked every 5 s the way ServerManager.swift does it. Tauri has no native status bar and drawing an HTML one under the work area would put the shell inside the page it is supposed to stay out of. Being offline opens a native window that says what has to be running, not an alert that vanishes when dismissed.
  • Security: main-frame navigation is confined to the Web URL's origin and everything else opens in the system browser; the bridge is main-frame only and the remote origin allowed to reach IPC is pinned in capabilities/; every native→web value is JSON-encoded rather than concatenated into JavaScript; external.open accepts only http, https, mailto, tel; fs.saveAs writes only where the person pointed. In a release build the API and Web URLs are compile-time constants — an installed shell cannot be aimed at a server it was not built for.
  • gerege:// deep links resolve to shell:navigate.
  • Auto-update is present and deliberately inert. The plugin is left uninitialised with TODOs in three places; an updater carrying no signing key is a mechanism for installing unsigned code, so it stays off until a key exists.
  • Two capabilities are withheld, and why is recorded. secure-store has no method in contract v1, so advertising it would be a claim nothing can act on — using it needs secure.get/set/delete added to the contract and a minor version bump. biometric.authenticate exists in the contract but Tauri's biometric plugin is mobile-only, so the capability is not declared and the call is rejected, which is what lets the web app fall back.
  • Not included: installers and code signing. The shell builds; shipping it needs a Developer ID identity plus notarisation on macOS and an Authenticode certificate on Windows, both listed as TODO in desktop-tauri/README.md.

Added — CI for both desktop shells

  • desktop-tauri.yml builds on Linux, Windows and macOS. Much of the shell sits behind #[cfg(target_os = ...)], so a green build on one machine says nothing about the other two. It runs cargo clippy --all-targets -- -D warnings, cargo build --locked and cargo test --locked, with fail-fast: false because one platform failing is the signal the job exists to produce.
  • It found a real break on its first run: tauri-build needs icons/icon.ico to generate the Windows resource, and the repository had only PNGs. Added icon.ico — sizes below 256 packed as classic DIB entries, since some resource compilers reject an all-PNG .ico — and icon.icns for macOS bundling.
  • desktop-mac.yml compiles the Swift shell and then checks the two things a successful swiftc cannot: that build.sh still names every file under src/ (a source missing from that fixed list is not a compile error — it is code that silently never ships), and that the produced bundle is one macOS would launch (Info.plist, an executable Mach-O, codesign --verify --strict).
  • The bridge fixes are guarded, not just documented. The job fails on a WKUserScript injected into subframes or on JavaScript built by string interpolation — the exact two shapes that were removed. Both guards were checked against the pre-fix sources to confirm they actually catch them rather than passing vacuously.
  • Neither workflow produces a distributable artifact, and both are filtered by path. A path-filtered workflow reports no status on runs that miss its filter, so making either a required check needs a merge queue or a companion job.

Added — Native Shell + Web Work Area

  • The web app now knows whether it is a whole product or part of one. Inside a native shell, sign-in, the header, the menus and device access belong to the shell; the web app hides its own chrome and renders as a work area. In a browser there is no shell, and everything below evaluates to nothing — the browser rendering is unchanged to the pixel, which is the constraint the whole design is built around rather than an afterthought.
  • One contract, written down (docs/SHELL_CONTRACT.md): injection rules, every method's parameters, result and failure, every event's payload, the capability names, the versioning rule (adding is minor, changing is major, and the shell announces its own version), and the security requirements a shell must meet. Two shells written by different people meet here or not at all.
  • window.GeregeShell in TypeScript (frontend/lib/shell.ts): getShell() returns null during SSR and in a browser, hasCapability(), a useShell() hook, and invokeShell() — an invoke that neither throws nor hangs, because callers mostly need to know whether the shell took the request, and "not supported", "failed" and "never answered" all mean the same thing: run the web fallback. Method, event and capability names are constants, so renaming one is a compiler error rather than a silent no-op.
  • Chromeless rendering (Layout.tsx): in a shell the top bar, sidebar, mobile tabs and drawer are not rendered at all, but the menu and user fetches still run — RBAC and access checks depend on them, and only the drawing is removed. The AI assistant stays; it is part of the work area.
  • Session expiry asks the shell first. There is no web /login page inside a shell, so a 401 calls auth.reLogin and falls back to router.push("/login") only if the shell will not, cannot, or does not answer — attempted once per session, so a re-login that leaves the session invalid cannot loop.
  • The two halves talk over the contract, not over URLs. A menu change tells the shell with menu.changed so it can rebuild its native menu; the shell moves the work area with shell:navigate (internal paths only — a protocol-relative //host is not one) and opens its search with shell:search.
  • Native-leaning styling, scoped by attribute (theme.tsx, globals.css): the shell's platform lands on <html data-shell>, which switches the app to the host's system font stack and chrome-free spacing, with a few per-platform touches. The attribute is absent in a browser, so no rule can reach it. The block sits above the density rules on purpose — a person who chose "compact" must not have it overruled by being in a shell.

Fixed — Security: the macOS shell's JavaScript bridge

  • Native results were concatenated into JavaScript. The biometric callback was assembled as onBiometricResult('\(cb)', \(success), '\(err)'), so a single quote anywhere in a system error message ran as code in the work area. The toolbar search field went the same way, which made anything the user typed a script. Every native→web value is now JSON-encoded and returned through one entry point (WebViewController.swift).
  • The bridge was injected into every frame. WKUserScript is now main-frame only, and each message is checked twice — isMainFrame, and that the frame's origin matches the platform's web origin. An embedded third-party page has no business reaching biometrics, files or notifications.
  • The main frame could navigate anywhere. It is now confined to an explicit allowlist — the web and API origins plus named identity origins — and every other address opens in the system browser rather than beside our session and our bridge. Deployments whose integration consent screens must stay in-app can name those origins in gerege_nav_allowlist; unlisted ones continue in the browser rather than breaking.
  • No functional regression: tray, toolbar, printing, downloads and gerege:// deep links continue to work, and deep links and menu items now move the work area through the router instead of reloading it, which no longer discards a half-filled form.

Added — Email verification as a platform capability

  • One flow instead of one per app (internal/platform/emailverify): proving that somebody controls an address is not one module's business. Contacts wants it before it trusts an address, Documents before a signing link leaves for an outsider, Gov Services before it answers a citizen at one. Each is the same act, so it lives in the platform: an app module takes the service in its constructor the way gov_services takes the integration manager and calls emailverify.Service.Send with its own app id as the source.
  • The mail is sent by a hosted service, deliberately. Delivering mail that arrives is not a matter of holding an SMTP password: it is SPF, DKIM, DMARC, reverse DNS and a sending reputation, maintained continuously. enigma.mn runs that, so this platform holds no mailbox credential, composes no message and owns no sender address. What stays here is what only this platform can know — which module asked, for whom, why, and whether the person came back.
  • The return is good exactly once (migrations 00026, 00027): the request carries a single-use reference in the return address, stored as a SHA-256, and claimed by one conditional UPDATE. A browser reloading the landing page races itself, and a reference that travelled through a mailbox and a browser's history must not be replayable. A spent, expired or invented reference is 410 alike.
  • The platform is not an open redirector: the onward destination is validated when the request is made — HTTPS only (HTTP tolerated for localhost outside production), no embedded credentials — not when somebody arrives, by which time the mail has gone.
  • Mail bombing has a cost: a per-tenant hourly allowance in front of the shared key and a one-minute pause per recipient, answered 429 with a Retry-After somebody can obey — a limit we can avoid provoking upstream is one we do not have to explain. A request the service refuses withdraws its own row, so the Overview screen never shows a verification nobody was asked for.
  • Errors say who has to act: a bad address is 400, a missing key or an HTTP PUBLIC_ORIGIN or a rejected key is 503 (this deployment, not the request), and a failure at the service is 502 and retryable. An answer nobody documented is never read as success.
  • Settings → Email verification: whether the service is reachable, what has been asked for and by whom, the verified rate, and a test send. No key management — keys belong to the sending service and are administered there, and this platform's copy is a server-side environment variable that never reaches a browser.
  • The page shown after a click exists in all seven platform languages. It is read outside the product, by somebody who may never have seen it.
  • Known limitation, stated on the screen: the service has no webhook yet, so a verification is recorded only when the person returns here. Somebody who confirms on another device and never comes back stays PENDING. That is the honest reading — this platform did not see it happen — and it is what the Overview screen says rather than something the code knows and the operator does not.

Added — PDF E-Sign v2: eID Mongolia qualified remote signing

  • eID Mongolia signature client (internal/platform/eidsign): a real relying-party client for the v3 signature API. The citizen's own device holds the private key and approves with PIN2, so nothing here ever touches a signing key: we hash the PDF, eID pushes that digest to the phone, and the signed document is assembled by eID's own doc-signer (POST /v3/signature/stamp/{sessionId}), which embeds the PKCS#7 together with OCSP and CRL data. Certificate level defaults to QUALIFIED — accepting ADVANCED would silently downgrade every document the ERP produces.
  • Asynchronous signing ceremony (/api/v1/esign/sign/init, /sign/{id}, /sign/{id}/download, /sign/{id}/cancel): upload → verification code → PIN2 on the phone → long-poll → PAdES-signed PDF. Sessions carry the exact bytes whose digest was approved, so a document edited mid-ceremony cannot produce a signature that fails to verify.
  • Signing on behalf of an organisation: representation rights are read live from the national registry rather than from a certificate, because a director who resigned yesterday still holds yesterday's certificate.
  • eID identity linkage (user_eid_identities, migration 00010): sign-in now records who a user is to eID. Without it every signature would make the citizen retype the registration number they had just authenticated with, and a typo would push the PIN2 prompt at somebody else's phone.
  • The five module screens are now real — signature log (filters, pagination, CSV export), batch signing, stamp placement (with an A4 preview), HSM connection (read-only, with a connection probe) and signing policy — replacing the /module/esign/* coming-soon placeholders.
  • Signing policy: a tenant can require qualified eID signatures and disable the HSM rail outright, including for callers hitting the API directly.

Fixed — PDF E-Sign

  • The app's permissions were declared but never enforced. io.example.esign is absent from the platform's blanket app gate (server.go) and its handlers only checked the tenant, so anyone in a tenant could sign. Every route now asserts esign.read, esign.sign or esign.manage explicitly. Migration 00010 backfills the grants existing roles should already have had, so no current user loses access.
  • esign.sign was ungrantable by the installer: the default-role rules key off a .read/.manage suffix, so only administrators would ever have received it.
  • The signature log recorded only successes, so a refused or expired ceremony left no trace — exactly the event an auditor looks for. Failures, refusals, expiries and downloads are now recorded with an outcome.
  • Non-ASCII download filenames were mangled: signed PDFs are now served with an RFC 5987 filename*, so a Cyrillic document keeps its name.
  • Truncated PDFs were accepted: a valid %PDF- header on a truncated body was passed to the signing service, which returned something that would not open. Uploads are now checked for a trailer as well as a header.
  • Sidebar sub-menus rendered as identical grey boxes: the icons named by the server's menu blueprints were never mapped in the frontend (Layout.tsx).

Added

  • PDF E-Sign App Module (io.example.esign):
  • PDF document upload with tenant-scoped storage (migration 00009), page-count detection, and original/signed download endpoints (/api/v1/esign).
  • Digital signature (тоон гарын үсэг) certificate validation and PKCS#7 PDF signing via the Gerege eSign HSM platform client (internal/platform/gerege/esign.go) — the private signing key never leaves the HSM.
  • Visible signature stamp placement with last-page auto-targeting and signature audit log (esign_signature_logs).
  • Frontend signing flow (/esign): certificate check → canvas signature pad → HSM signing → signed PDF download.
  • Mock mode by default (ESIGN_MOCK_MODE); configure ESIGN_LOGIN_URL, ESIGN_SIGN_URL, and ESIGN_TOKEN for live signing.

Fixed — CI/CD pipeline

  • Go toolchain mismatch broke every job: backend/go.mod requires go 1.25.7 while the workflows pinned go-version: "1.24" and both Dockerfiles used golang:1.24-alpine (which sets GOTOOLCHAIN=local, so the build hard-fails). All jobs now resolve the version from backend/go.mod and the image builder sets GOTOOLCHAIN=auto.
  • Security workflow could never pass: govulncheck ./... and gosec ./... ran at the repository root, which contains no go.mod. They now run against backend/. The workflow also only triggered on PRs to master while the default branch is main.
  • GHCR push lacked packages: write, so deployment failed with denied: installation not allowed to Write the repository.
  • Removed the swag-drift job: the sources carry no swagger annotations and backend/docs/ is untracked, so it could only fail or pass vacuously.
  • deploy.yml no longer duplicates lint/test from ci.yml; it runs migrations before swapping the API over, uses docker compose, lower-cases the GHCR image path, and skips cleanly when deployment secrets are absent.
  • Added a frontend CI job (npm ci + tsc --noEmit + next build) — the Next.js app was never built by CI.
  • Added .dockerignore, a pinned backend/.golangci.yml (v2), deleted the duplicate backend/Dockerfile, untracked committed .DS_Store files, and gofmt-ed the 11 files that had drifted.
  • docker-compose.yml: added a one-shot migration service (the API used to start against an empty schema), health checks, and build-time NEXT_PUBLIC_API_URL; frontend/Dockerfile now uses npm ci with the lock file. Database credentials are consistent across compose, .env.example and the Makefile.

Fixed — Security

  • Session tokens were the user's UUID, the same value returned by /api/v1/auth/me. Replaced with opaque 256-bit crypto/rand tokens stored as SHA-256 digests in a new sessions table, with expiry and real revocation on logout (logout previously only dropped the cookie).
  • Mock national-identity mode was on by default (os.Getenv(...) != "false"), so in production /auth/eid/login and /auth/dan/login accepted any registration number and logged the caller in as the first user in the table with is_admin: true. Mock mode is now refused in production unless requested explicitly, and identities are matched against a real ERP user.
  • OAuth2 token endpoint accepted any known client_id with no secret (clientSecret != "" && ... skipped the check entirely). Client authentication is now mandatory and constant-time, supports HTTP Basic, validates the grant type, and is also enforced on /oauth2/introspect and /oauth2/revoke.
  • Removed the hard-coded client secret secret_gerege_dev_2026; ListClients no longer discloses client secrets.
  • App install/enable/disable and integration registration now require a tenant administrator — any authenticated user could previously reconfigure the tenant.
  • Login rate limiting no longer trusts X-Forwarded-For unless TRUST_PROXY_HEADERS=true.
  • Halved-entropy generateRandomString (hex output truncated back to n) fixed.
  • /metrics no longer labels unmatched routes with the raw request path — unbounded Prometheus cardinality driven by unauthenticated requests.

Fixed — App store & modules

  • Billing, Documents and the Developer Portal could not be installed: their modules were never registered in appregistry, their rows were missing from the apps table (foreign-key violation), and developer_portal was rejected by the slug validator, which forbade underscores.
  • The apps table is now synchronised from catalog/apps.json on boot instead of a hand-maintained INSERT that listed three of six apps.
  • Three manifests were malformed ("dependencies": {} instead of an array, permissions as plain strings, depends/sequence/action keys). They parsed into a silent stub with no dependencies, permissions or menus. Manifests are fixed, and a manifest that fails to load is now a startup error.
  • Billing and Documents no longer create their tables at boot with the error discarded; the schema moved into migration 00004. Both stop answering failed writes with fabricated demo records (inv_demo_100, doc_demo_200).
  • The app store reported disabled apps as "not installed" because installed and enabled were both derived from the enabled-only query.
  • Fixed a nil-interface panic in InstallApp when a module was missing from the registry, and a nil-pointer dereference in the E-ID/DAN login handlers that called err.Error() on a nil error.

Fixed — Reliability

  • AsyncOTPMailer.Shutdown closed the queue while workers and retries could still send on it (panic: send on closed channel) and dropped already-queued mail; it now drains, is idempotent, and refuses post-shutdown enqueues.
  • AI Copilot intent classification was case-sensitive against a lowercase keyword table, so "Stock" never matched.
  • Restored demo-data seeding (dropped from cmd/api), now idempotent and disabled in production unless SEED_DEMO_DATA is set.

[0.1.0] - 2026-08-05

Added

  • Modular Monolith Core Architecture:
  • Pure Go compile-time Module interface and global module registry (appregistry).
  • Tenant-level app installation, enablement, and menu visibility engine (appinstaller).
  • ORY Hydra-Grade OAuth2 & OpenID Connect (OIDC) SSO Provider (internal/platform/ssoprovider):
  • OpenID Connect Discovery (/.well-known/openid-configuration), JWKS URI (/.well-known/jwks.json), and OAuth2 Authorization Server (/oauth2/token, /oauth2/introspect, /oauth2/revoke).
  • Supports authorization_code, client_credentials, and refresh_token grant flows.
  • Developer Portal App Module (io.example.developer_portal):
  • Developer portal interface (/developer/apps) to register third-party OAuth2 client applications, issue Client IDs and Client Secrets, and manage redirect URIs.
  • Automated Production Deployment & CI/CD Pipeline (openerp.gerege.mn):
  • Continuous Integration & Automated Deployment pipeline building GHCR Docker images and deploying to openerp.gerege.mn.
  • Production Multi-Stage Dockerfile (deploy/Dockerfile) and Nginx SSL Reverse Proxy config (then deploy/nginx/openerp.gerege.mn.conf, since renamed to deploy/nginx/nexus.gerege.mn.conf).
  • Recursive dependency resolution algorithm with cycle detection and semver validation.
  • Shared-Schema Multi-Tenancy:
  • Context-scoped tenant_id isolation across all business entities and repositories.
  • Tenant app gating middleware returning 403 Forbidden for disabled modules.
  • Business Modules (Vertical Slices):
  • Contacts (io.example.contacts): Business contacts directory with full CRUD.
  • Products (io.example.products): Product catalog management with unique tenant-scoped SKUs.
  • Inventory (io.example.inventory): Warehouse management, live stock levels, append-only stock movement log, and transactional stock adjustments with negative stock protection.
  • Next.js App Router Admin Shell:
  • Top navigation bar with tenant badge (Demo Corporation), user profile menu, and logout.
  • Dynamic sidebar navigation driven by /api/v1/menus.
  • App Store (/apps) with search, categories, dependency badges, and Install/Enable/Disable controls.
  • Installed Apps Settings (/settings/apps).
  • Dedicated business UIs for /contacts, /products, and /inventory.
  • High-Performance Resilience Engine (go-zero Inspired):
  • Adaptive Circuit Breaker (resilience/breaker.go): Google SRE style sliding window adaptive circuit breaker.
  • Adaptive Load Shedding (resilience/loadshedder.go): In-flight HTTP request concurrency shedder returning 503 Service Unavailable under heavy traffic spikes.
  • Singleflight Coalescing (resilience/singleflight.go): Duplicate query suppressor preventing thundering herd cache stampedes.
  • Exponential Backoff Retry (resilience/retry.go): DoWithRetry execution helper for resilient DB/network operations.
  • Observability & Async Messaging:
  • Prometheus metrics endpoint (/metrics) recording HTTP request rates and latency histograms (github.com/prometheus/client_golang).
  • OpenTelemetry tracing initialization (SetupTracing).
  • Async OTP Mailer queue with worker pool, retry logic, and graceful shutdown (internal/platform/mailer).
  • Public Billing & e-Barimt Module (io.example.billing):
  • Public service fee invoices, 10% VAT calculation for Mongolia e-Barimt, and status tracking (/billing).
  • Gerege DAN SSO Gateway System (dan.gerege.mn):
  • Official Gerege Systems DAN SSO Gateway integration service (POST /api/v1/auth/dan/login).
  • Citizen identity verification and session token validation against https://dan.gerege.mn/api/v1.
  • E-ID Digital Identity & DAN SSO Authentication (internal/platform/eid):
  • Aligned 100% with official eidmongolia.mn & developer.gerege.mn OAuth2 and OpenID Connect (OIDC) specifications.
  • Supports 4 official Mongolian authentication channels: PKI Digital Signature (Тоон гарын үсэг), Mobile OTP, Bank SSO, and Biometric Face Verification.
  • External System Integrations & Webhook Engine (internal/platform/integration):
  • Event Dispatcher & Connector Manager supporting HMAC-SHA256 signature signing, asynchronous webhooks, and third-party REST connectors.
  • Dedicated Integration Settings Manager UI (/settings/integrations) with real-time status & health tracking.
  • XYP State Data Exchange System (xyp.gerege.mn):
  • Official Mongolian State Data Exchange (ХУР Төрийн мэдээлэл солилцооны систем) integration service.
  • Citizen civil registration (POST /api/v1/xyp/citizen) & company legal entity verification (POST /api/v1/xyp/company).
  • Interactive "ХУР / XYP Auto-fill" button integration on Contacts page.
  • Database & Migrations:
  • Goose SQL migrations (00001_platform_core.sql, 00002_app_store.sql, 00003_business_apps.sql).
  • Automated initial demo data seeder (admin@example.com / Password123!).

Inspirations & Acknowledgements

  • snykk/go-rest-boilerplate by @snykk: Initial Go REST API structure.
  • Odoo: Modular app ecosystem, App Store dependency resolver, and dynamic menu architecture.
  • go-zero: High-performance cloud-native resilience engine (Adaptive Circuit Breaker, Load Shedder, Singleflight, Exponential Retry).

Authors & Contributors

  • Gerege Systems Development Team
  • Gemini AI
  • Claude AI