Routing — Empat Jenis Route & Cara Resolve
Status: berlaku untuk renderers/react-shadcn (shell resmi) · Kode otoritatif: src/shell/router.tsx, src/App.tsx, internal/ui/meta.go, pkg/spec/frontend.go
Dokumen ini menjawab satu pertanyaan: untuk URL tertentu, siapa yang membuat route itu, komponen apa yang dirender, dan bagaimana spec Form/Table-nya dipilih. Sebelum ini jawabannya tersebar di empat berkas kode tanpa satu pun ringkasan, dan empat salinan konvensi route yang sama sudah pernah menyimpang satu sama lain (lihat §8).
1. Komposisi URL — "paling depan" bukan menu
Route FormSpec dibaca dari kiri ke kanan dalam lima lapis, dan menu bukan salah satunya. Menu adalah konsumen route: ia menyimpan jalan pintas ke route yang sudah ada, dan item yang route-nya tidak ada justru dibuang (§5).
| # | Lapis | Contoh (kafe) | Ditetapkan oleh |
|---|---|---|---|
| 1 | Workspace | kafe | slug workspace (--workspace-id) |
| 2 | Mount prefix | app/pos | App.spec.root_url |
| 3 | Segmen route | cafe-master/promos | spec.route Page, atau konvensi (§2) |
| 4 | Param | :id | pola route dinamis |
| 5 | Query overlay | ?action=create&form=promo-form&mode=drawer | aksi UI (jalur D) |
Setiap permukaan adalah App — tidak ada lapis 2 khusus. (Historis: dulu _admin mengganti lapis 2 dengan literal _admin dan bundle unscoped ?admin=true; keduanya sudah dipensiunkan — plan app-scoped-login.md D4.)
Cara prefix dipasang, dan satu jebakannya
buildRoutes({ basePath }) menerima basePath = surface path penuh (/{ws}/_admin atau /{ws}{root_url}), lalu menempelkan segmen route di belakangnya. Tetapi <Routes> bersarang di App.tsx hanya menerima sisa setelah mountPrefix — dan mountPrefix adalah /{ws}/app bukan root_url:
// App.tsx
const surfacePath = `/${workspace}${bundle?.app.root_url ?? "/app"}` // /kafe/app/pos
const mountPrefix = `/${workspace}/app` // /kafe/appAkibatnya, untuk root_url yang bukan /{ws}/app, path relatif yang benar-benar dicocokkan memasukkan segmen app/<nama-app>:
| URL nyata | basePath (buildRoutes) | path relatif yang dicocokkan |
|---|---|---|
/kafe/app/pos/cafe-master/promos | /kafe/app/pos | app/pos/cafe-master/promos |
/kafe/cafe-master/promos (root_url: /) | /kafe | cafe-master/promos |
Ini pernah menjadi bug "double /app" (docs_internal/plan/todo_fix_clinic.md §2) — komentar di App.tsx mencatatnya supaya path relatif selalu dihitung dengan membuang mountPrefix, bukan surfacePath.
Resolusi App pada URL tak ber-App
WorkspaceRoute (App.tsx) melayani /{ws}/* dan memilih App lewat detectApp() (stores/meta.ts) — prefix root_url terpanjang yang cocok, dengan root_url: "/" sebagai kecocokan terakhir. Kalau tidak ada App yang mengklaim path itu, pengguna melihat halaman 404 jujur (dulu dialihkan ke _admin, yang sudah dipensiunkan). Ini penting untuk workspace dengan banyak App (kafe: kafe-pos /app/pos, kafe-kds /app/kds, kafe-qr /).
Identifier record pada URL dan breadcrumb
Route detail memakai identifier record dalam satu path segment. Saat Entity memiliki tepat satu field natural_key, renderer memakai nilai field tersebut untuk link tabel, Listing, Kanban, route detail/edit, dan breadcrumb. Jika natural key tidak ada atau nilainya kosong, renderer memakai UUID primary key.
unique: true saja tidak memilih identifier URL karena satu Entity dapat memiliki beberapa field unique. natural_key implisit unik — cukup tulis natural_key: true, dan field dibatasi maksimal satu per Entity. URL memakai nilai key begitu record memilikinya (mode apa pun: user_entry, auto_generated_if_empty, maupun auto_generated yang nilainya dibuat engine); bila key belum ada atau kosong, renderer memakai UUID primary key. Nilainya di-encode sebagai path segment. URL lama berbasis UUID tetap diterima backend; breadcrumb mengganti UUID dengan natural key setelah detail record berhasil dimuat.
Mode entry juga menentukan apakah key muncul sebagai input di form: auto_generated tidak (nilai yang dikirim caller dibuang engine), sedangkan user_entry dan auto_generated_if_empty ya.
Menulis yang mengubah identifier — URL ikut pindah
Karena natural key adalah alamatnya, PATCH yang mengubahnya memindahkan record: alamat lama berhenti me-resolve (server membalas 404 untuk key lama, 200 untuk yang baru). Client menanganinya di satu tempat, renderers/react-shadcn/src/hooks/useRecordIdentity.ts:
- Setelah write, identifier diambil dari record yang dikembalikan server, bukan payload yang dikirim — key yang di-
auto_generated(atau dibuang) tidak pernah ditentukan caller, jadi payload bisa menamai alamat yang tidak pernah ada. - URL hanya berpindah bila halaman memang tinggal di record itu (
recordAddressFor,shell/routeModel.ts): route detail/edit. List, Page authored, atau overlay di atas halaman lain bukan alamat record ini — memindah pengguna di sana adalah navigasi yang tidak diminta. Form yang menavigasi sendiri (submit.redirect, atau kembali ke list) juga tidak diikuti: tujuannya sudah alamat sendiri (writeStaysOnRecord). - Navigasi memakai
replace, bukan push: identifier yang ditinggalkan adalah yang dipakai pengguna saat datang, dan Back tidak boleh menuju 404. - Pada pembacaan, URL tidak berpindah. Alamat berbasis UUID tetap sah dan crumb-nya hanya berganti label setelah record termuat.
Sisi server punya syarat yang sama, dan pernah melanggarnya: HandleUpdate me-re-read record dengan id dari request dan membuang error-nya (rec, _ := store.GetByID(...)), lalu men-dereference hasilnya — PATCH yang mengganti natural key membalas 500 panic setelah transaksinya commit (terukur di kafe: key baru sudah hidup, key lama 404, dan caller diberi tahu bahwa write-nya gagal). Kini re-read memakai current.ID — identitas stabil (primary key) yang sudah dibaca handler sebelum merge — dan kegagalannya dilaporkan jujur, bukan panic.
Breadcrumb: dibangun dari route model, bukan dari path
Breadcrumb tidak memecah pathname. Ia mengklasifikasi sisa path setelah surfacePrefix lewat src/shell/routeModel.ts matchRoute — keluarga route yang sama dengan yang didaftarkan buildRoutes — lalu menyusun trail dari hasilnya (src/shell/breadcrumbs.ts).
Kenapa: memecah path membuat mount prefix App ikut menjadi level navigasi. Untuk /kafe/app/pos/cafe-order/orders, crumb lama berbunyi App › Pos › Cafe Order › Orders; App menaut ke /kafe/app (tidak ada App di sana) dan Cafe Order ke /kafe/app/pos/cafe-order (tidak ada route) — dua taut mati, dan seluruh label berupa potongan URL, bukan nama navigasi yang sudah ditulis di bundle.menu.
Aturannya sekarang:
| Route | Trail |
|---|---|
/<module>/<plural> | jejak menu ke list itu; Transaksi › Pesanan |
…/:id | trail list, lalu identitas record (natural key) |
…/:id/edit | + langkah Edit (menaut balik ke detail) |
…/new | trail list, lalu New |
| Page authored | spec.title, atau jejak menu bila page-nya dikurasi |
Page turunan B (/table/) | entity yang di-browse (bukan order-table-pos) |
Named view (/kanban/…) | jejak menu, atau spec.title / nama view yang di-humanize |
Dua konsekuensi yang disengaja:
- Hanya level yang punya route yang jadi tautan. Grup menu (mis.
Transaksi) tidak punya route — ia dirender sebagai teks. Jadi setiaphrefyang keluar dari breadcrumb sudah pasti route terdaftar; tidak ada crumb yang bisa menuju 404. - Identifier record tidak pernah tampil sebagai UUID di trail. Selama detail request belum landing, crumb terakhir adalah placeholder (
Skeleton); setelah landing ia menjadi natural key (storestores/routeIdentity.ts).
Model ini bisa drift dari router, jadi ia dijaga test: routeModel.test.ts menjalankan buildRoutes yang asli, mengambil setiap .path, menggantinya :param menjadi nilai konkret, dan gagal bila matchRoute menjawab unknown. Menambah keluarga route tanpa menambah case di routeModel.ts gagal di test, bukan di browser.
2. Empat jenis route
Hanya satu yang ditulis tangan; tiga lainnya dihasilkan. Jalur D bukan jenis route kelima — ia adalah jalur A (atau C) plus query string.
A. Authored Page — kind: Page
| Aspek | Nilai |
|---|---|
| Dibuat oleh | penulis manifest (spec.route) |
| Didaftarkan di | router.tsx blok // 1. Page routes |
| Path | basePath + spec.route |
| Komponen | PageRenderer |
| Form dipilih oleh | block.form.ref → getForm(name) → resolveForm(explicitRef) |
| Mode default | block.form.mode ?? "view" |
| Gerbang | page.spec.permissions (bundle, server) + guard sesi public |
Contoh kafe: /pos (pos-workbench.yaml, blok table + form split), /menu/:session_id.
B. Derived Page untuk Form/Table — dibuat server
| Aspek | Nilai |
|---|---|
| Dibuat oleh | server — makeDerivedPage (internal/ui/meta.go) |
| Didaftarkan di | router.tsx lewat bundle.pages (tidak bisa dibedakan dari A) |
| Nama entry | <nama-form>-page |
| Path | /<module>/form/<nama> atau /<module>/table/<nama> |
| Form dipilih oleh | block.form.ref = nama Form itu (satu-satunya) |
| Mode | selalu view — block-nya dibuat tanpa mode |
Dua gerbang yang membuat jalur B tidak selalu ada, dan keduanya harus diingat saat menautkannya:
spec.public: falsepada Form → derived Page dilewati.- Form sudah direferensikan
block.form.refdi Page lain → ditandaicovered→ derived Page tidak dibuat.
C. Derived entity CRUD — dibuat klien
| Aspek | Nilai |
|---|---|
| Dibuat oleh | buildRoutes (router.tsx blok // 2.) |
| Path | basePath + /<module>/<plural> + [/new | /:id | /:id/edit] |
| Komponen | TableRenderer (list), DetailPage (:id), FormRenderer (new/edit) |
| Form dipilih oleh | konvensi resolveForm — tanpa formRef |
| Gerbang | entity.authorized_actions per route (allowsRoute) |
Karena tidak ada formRef, FormRenderer jatuh ke konvensi nama dan akhirnya bisa menderivasi sendiri (§4). Route /new dan /:id selalu didaftarkan sebagai route — bila aksinya tidak diizinkan, isinya RouteNotFound, supaya /new tidak tertelan :id.
Pada route CRUD, :id berarti natural_key bila tersedia, atau UUID primary key sebagai fallback. Keduanya diterima oleh endpoint detail/action.
D. Overlay — bukan route, melainkan query string
| Aspek | Nilai |
|---|---|
| Dipicu oleh | klik New/Edit di TableRenderer → setSearchParams |
| Kontrak query | ?action=create|edit + form=<nama> atau entity=<module.name> + id= + mode= |
| Dibaca oleh | OverlayHost (shell/OverlayHost.tsx) |
| Form dipilih oleh | getForm(formName); fallback resolveForm bila hanya entity= |
| Mode | dari URL (action==="edit" ? "edit" : "create"), container dari spec.render.mode |
E. Auth screen — root permukaan
Bukan salah satu jalur A–D: tidak dihasilkan buildRoutes, melainkan didaftarkan langsung di App.tsx.
| Aspek | Nilai |
|---|---|
| Slot | change_password_page (login/register/setup/reset/oauth punya jalurnya sendiri) |
Path _admin | /{ws}/_admin/change-password — route statis top-level, di luar SurfaceShell (tanpa chrome) |
| Path App | /{ws}{root_url}/change-password — route bersarang di dalam SurfaceShell (dengan chrome) |
| Komponen | AuthPage — Page override dari App.spec.auth.<slot> bila ada, jika tidak aset bawaan shell |
Path App tidak bisa dideklarasikan statis karena root_url bebas; ia dihitung sebagai surfacePath − mountPrefix (§1) dan karena itu ikut aturan yang sama dengan route root App. Tanpa pendaftaran per-permukaan, item "Change Password" di user menu (useSurface().surfacePath) menabrak catch-all "Page not found" di setiap permukaan App.
Cara TableRenderer memilih Form yang dikirim inilah satu-satunya pemilihan yang tidak deterministik hari ini:
// kinds/table/TableRenderer.tsx — match PERTAMA
return metaBundle.forms.find((f) => { ...same module+entity... })Bundle forms diurutkan alfabetis (sortedKeys, internal/ui/meta.go), jadi dengan >1 Form untuk satu entity, yang menang adalah yang namanya lebih dulu — bukan yang paling cocok. Kafe punya contoh nyata: entity order punya order-form-pos dan order-form-qr, sehingga tombol New di /cafe-order/orders memakai order-form-pos (layout mode edit) walau sedang membuat record baru.
3. Cara membedakan A, B, C, D
Empat sinyal, dari yang paling murah:
| Sinyal | A | B | C | D |
|---|---|---|---|---|
| Pola URL | route authored | /<module>/form|table/<n> | /<module>/<plural>[/new|/:id] | sama seperti A/C + ?action= |
Ada file kind: Page dengan route: itu? | ya | tidak | tidak | ya/tidak (induknya) |
Muncul di bundle.pages sebagai <n>-page? | tidak (nama authored) | ya | tidak | — |
FormRenderer menerima formRef? | ya (kalau block pakai ref) | ya | tidak | ya (dari ?form=) |
flowchart TD
U["URL dibuka"] --> Q1{"Ada file kind: Page<br/>dengan route itu?"}
Q1 -->|ya| A["A — Page authored<br/>block.form.ref"]
Q1 -->|tidak| Q2{"Ada query param<br/>action=&form=?"}
Q2 -->|ya| D["D — overlay dari Table<br/>(= A, ref eksplisit)"]
Q2 -->|tidak| Q3{"Segmen akhir<br/>'form' atau 'table'?"}
Q3 -->|ya| B["B — derived Page<br/>/module/form/<name> (mode view)"]
Q3 -->|tidak| C["C — derived CRUD<br/>/module/<plural>…"]Contoh kafe untuk entity promo
| URL | Jalur | Form dipakai | Mode |
|---|---|---|---|
/kafe/app/pos/cafe-master/promos | C | — (Table) | — |
.../promos?action=create&form=promo-form&mode=drawer | D | promo-form (?form=) | create |
.../promos/new | C | promo-form (konvensi {entity}-form) | create |
.../promos/123/edit | C | promo-form (konvensi {entity}-form) | edit |
.../cafe-master/form/promo-form | B | promo-form (satu-satunya) | view |
4. Cara resolve
Dua sumbu terpisah yang mudah dicampur.
Resolve route → komponen
buildRoutes membangun tabel route per surface; allowsRoute(entity, action) membaca entity.authorized_actions yang di-resolve server dengan checker yang sama yang memutuskan entity ikut bundle. Jadi klien tidak menebak dari lifecycle. Tanpa authorized_actions (server lama) semua route terdaftar dan datanya yang menolak.
Resolve Form → spec
resolveForm (engine/derive.ts) berurutan:
explicitRef(dariform.ref/?form=) — menang mutlak, apa pun modenya.{entity}-{mode}—promo-create/promo-edit/promo-view.{entity}-form— generik untuk semua mode.deriveForm(entity, mode)— hasil turunan dari schema entity.
Dua hal yang paling sering salah dibaca:
spec.modepada Form TIDAK memilih mode runtime.FormRenderermenerimamodedari pemanggilnya (route:create/edit; block:block.form.mode, defaultview).promo-formyang menulismode: edittetap dipakai untuk create. Nilai itu dipakai server-side: permission Page turunan (formActionPerm,internal/ui/meta.go) dan footprint grant (internal/auth/materialize.go).- >1 Form untuk satu entity hanya deterministik lewat
form.refatau konvensi nama. Pola yang benar sudah ada di repo:visit-create+visit-edit(examples/Clinic-UI-Showcase) — dua Form, dibedakan mode oleh konvensi. Tidak ada field semacamdefault_formdiTableSpec, jadi "Form mana yang dibuka tombol New" tidak bisa dinyatakan di manifest untuk jalur D — ia mengikuti urutan alfabetis.
Resolve menu view → route
ResolveViewRoute (internal/ui/registry.go) memetakan nama View ke route:
| Kind | Route |
|---|---|
| Page | spec.route-nya sendiri |
| Form | /<module>/form/<nama> |
| Table | /<module>/table/<nama> |
| Dashboard / Widget / Report / Wizard / Kanban / Timeline / Calendar / Listing / Print / ApprovalInbox / NotificationCenter | /<prefix>/<nama> |
5. Lapisan menu
Menu adalah indeks, bukan pangkal route. Tiga fakta yang menjelaskan hubungannya:
Module.spec.menu= saran;App.spec.menu= otoritatif. Adopt node (type: module, hanya level 1) menyisipkan menu modul di posisi itu;stampModulemengisimodulepada tiap leaf karena item modul ditulis module-relative. Maksimum 3 level.- Leaf wajib
label+module+ tepat satu dariview/route(internal/app/resolve.go).viewdi-resolve ke route saat App di-resolve;routeadalah escape hatch untuk URL mentah. filterMenuberjalan terakhir diBuildBundle, setelah entity, page, dashboard, dan widget final. Ia membuang item yang route-nya tidak dilayani bundle atau yangpermissions-nya tidak dipegang pemanggil (§6).
Konsekuensinya: route yang tidak ada di menu tetap hidup, dan route yang tidak ada tidak bisa diselamatkan menu. DefaultRedirect menghantar pengguna ke firstMenuRoute pada surface App, atau entity non-summary pertama pada _admin.
Tiga perubahan perilaku terbaru di lapisan ini
| Perubahan | Alasan |
|---|---|
permissions: pada item menu disaring server | RBAC yang diperiksa klien bisa di-bypass dan bisa menyimpang diam-diam dari server. Field-nya sebelumnya bahkan tidak ada di pkg/spec, padahal klien memeriksanya — cek mati. |
when: dievaluasi klien | Bergantung waktu (today()); bundle di-ETag-hash, jadi filter waktu di server akan membatalkan cache terus-menerus. |
Listing bisa jadi target view | ResolveViewRoute tidak punya cabangnya sementara viewKinds (validator) mengizinkannya — validate hijau, App gagal resolve (§8). |
6. Visibilitas menu: dua sumbu, lima lapis
| # | Lapis | Sisi | Bypass-able | Menahan apa |
|---|---|---|---|---|
| 1 | Entity visibility (BuildBundle) | server | ❌ | entity tanpa list/view tidak masuk bundle → route tak pernah didaftarkan |
| 2 | filterMenu — route harus ada di bundle | server | ❌ | tautan ke route yang tidak dilayani |
| 3 | MenuItem.permissions (any-of) | server | ❌ | RBAC eksplisit per item |
| 4 | required_permission per action + row scope | server | ❌ | setiap panggilan data |
| 5 | MenuItem.when | klien | ✅ memang | kenyamanan UX saja |
whenbukan gerbang otorisasi. Menyembunyikan item menu tidak memberi atau menolak akses apa pun: route tetap ada, dan datanya tetap dijaga lapis 1–4. Yang "didapat" dengan memaksawhenbernilai true adalah melihat sebuah tautan — sama seperti mengetik URL-nya langsung. Karena itu:
- RBAC →
permissions:(digerbangi server).- Kondisi bisnis/waktu →
when:(klien).Kesalahan yang pernah terjadi di repo ini:
when: "user.has('clinic.settings.update')"— bentuk yang (a) tidak bisa dievaluasi evaluator mana pun, dan (b) melanggar08-formspec-expr.md§3 yang melarang identitas/permission di FormSpecExpr. Item itu tampil untuk semua orang sambil terlihat dijaga.formspec checkkini menolaknya (§7).
when gagal dievaluasi → item ditampilkan
Fail-open, disengaja. Menyembunyikan navigasi karena bug renderer tidak bisa dibedakan dari "item ini memang tidak ada", dan karena when bukan batas keamanan, menampilkannya tidak membocorkan apa pun. Kegagalannya dilaporkan ke console supaya tetap terlihat. Gate deploy-time (§7) membuat jalur ini seharusnya tidak tercapai dari spec yang tervalidasi.
7. Gate deploy-time
formspec check memeriksa setiap FormSpecExpr, termasuk yang baru:
| Tempat | Diperiksa |
|---|---|
Form: visible_when/readonly_when/required_when/compute | referensi field + grammar + callable |
| Wizard step field (sama) | idem |
Kanban drag_guard | idem |
Menu when (App & Module) | grammar + callable (tidak ada schema entity untuk dicek) |
Himpunan callable tertutup: len, sum, amount, currency, today. Nama lain — termasuk user.has(...), session.x(), dan salah ketik seperti leng(...) — adalah error deploy-time, bukan warning runtime. Ini menutup kelas bug di mana ekspresi yang tidak bisa dievaluasi klien lolos validasi lalu mati saat runtime ("unknown function: undefined"), padahal §4 spec menjanjikan ekspresi yang lolos apply dapat dievaluasi.
guard: {expression} pada state machine tidak memakai himpunan ini: itu Starlark sungguhan (internal/starlark.EvaluateGuard) dengan builtin tambahan (sum_line), kontrak berbeda.
8. Divergensi yang tercatat
Empat salinan konvensi route harus sepakat:
| # | Salinan | Berkas |
|---|---|---|
| 1 | ResolveViewRoute | internal/ui/registry.go |
| 2 | routeExists / navigationPrefix | internal/ui/meta.go |
| 3 | buildRoutes | src/shell/router.tsx |
| 4 | viewKinds | cmd/formspec/validate_dangling.go |
Kegagalan pertama yang nyata: Listing ada di #2, #3, #4 tetapi hilang di #1. Akibatnya formspec validate menerima view: product-catalog, lalu app.Resolve mengembalikan "view not found" dan App gagal resolve seluruhnya. Contoh examples/storefront lolos hanya karena memakai escape hatch route: /listing/product-catalog. Kini ditutup, dan internal/ui/registry_test.go mem-pin tabel lengkapnya — termasuk test paritas lintas-lapis yang menyalin daftar viewKinds dengan sengaja, agar kegagalan berikutnya muncul di test, bukan di produksi.
Dua dokumen juga sempat bertentangan tentang Form/Table sebagai target view: docs/spec/platform/02-workspace-app-module.md §4 menyebut keduanya bukan target yang sah, sedangkan docs/kind/curation/App.md menyebut semua kind visual sah. Kode mengikuti yang kedua (dan internal/auth/materialize.go bergantung padanya lewat {entity}-page). Sudah diperbaiki di spec; dipin oleh test.
Sisa yang belum ditutup
routeExistsbertanya ke daftar Form, bukan kebundle.pages. Ketika derived Page disuppress (§2 jalur B), item menu ber-view: <form>tetap lolos filter sementara route-nya tidak terdaftar → 404. Todo 5.22.6.— SELESAI dengan dihapusnya surface_adminbutapermissions/when_admin(planapp-scoped-login.mdD4). Tidak ada lagi bundle always-true: setiap bundle App-scoped dan permission-filtered, jadi sidebar tidak mungkin menampilkan entity yang endpoint-nya akan 403.— Selesai bersama butir 2 (permukaan yang bersangkutan sudah tidak ada).MenuItem.whentidak berlaku di_admin- Pemilihan Form jalur D tidak deterministik saat >1 Form untuk satu entity (§2 jalur D). Tidak ada field manifest untuk menyatakannya; solusi hari ini hanya penamaan/
form.refeksplisit.
9. Resep diagnosis — "URL ini jalur apa?"
- Lihat URL. Ada
?action=? → jalur D. Segmen akhirform/table? → jalur B. Berakhir/<plural>atau…/newatau…/:id/edit? → jalur C. Selain itu → jalur A. - Konfirmasi dengan manifest.
grep -rn "route: <path-relatif>" examples/<app>/spec— ada hasil berarti jalur A (filekind: Page), tidak ada berarti B atau C. - Periksa bundle.bashPage bernama
curl -s "$BASE/kafe/_ui/_meta/ui?app=kafe-pos" -H "Authorization: Bearer $TOKEN" \ | jq '{pages: [.data.pages[]|{name, route: .spec.route}], forms: [.data.forms[].name], entities: [.data.entities[]|.module+"/"+.name]}'<sesuatu>-pagedengan route/…/form/…= jalur B. Catatan: App publik tanpa token hanya memberi bundle kosong — perlu kredensial. - Periksa argumen form. Form yang tampil sebagai halaman penuh tanpa
?form=(jalur C) memakai konvensiresolveForm; kalau tidak ada Form authored yang cocok konvensi, yang dirender adalah hasil turunan — dan itu bukan bug, melainkan fallback yang tak kelihatan.
Gejala → penyebab
| Gejala | Kemungkinan penyebab |
|---|---|
| Tombol New memakai layout Form "edit" | >1 Form untuk entity yang sama; jalur D mengambil match pertama alfabetis |
Form authored tidak dipakai di …/new | namanya tidak mengikuti konvensi {entity}-create/-edit/-form |
Halaman penuh terbuka mode view padahal Form punya mode: edit | jalur B — derived Page dibuat tanpa mode |
| Item menu menuju 404 | derived Page disuppress (public: false / covered) — sisa §8 butir 1 |
view: <listing> membuat App gagal mount | sudah ditutup; bila muncul lagi, periksa keempat salinan konvensi §8 |
when tidak menyembunyikan apa pun | ekspresi tidak bisa dievaluasi (fail-open) → lihat console; formspec check menolaknya |
10. Cross-ref
01-architecture.md§3–§4 — bootstrap & konsumsi Spec Resolution API02-derivation-engine.md§3–§4 — presedensi authored > derived../spec/frontend/04-spec-resolution-api.md— kontrak_meta/ui& permission filtering../spec/frontend/06-page-kinds.md§1–§2 — Page & Form../spec/frontend/08-formspec-expr.md— grammar & konteks evaluasi../spec/platform/02-workspace-app-module.md§4 — kontrak menu- Plan:
docs_internal/plan/routing-docs-and-menu-visibility.md,docs_internal/plan/breadcrumb-route-model.md(breadcrumb §1)