Skip to content

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).

#LapisContoh (kafe)Ditetapkan oleh
1Workspacekafeslug workspace (--workspace-id)
2Mount prefixapp/posApp.spec.root_url
3Segmen routecafe-master/promosspec.route Page, atau konvensi (§2)
4Param:idpola route dinamis
5Query overlay?action=create&form=promo-form&mode=draweraksi 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:

tsx
// App.tsx
const surfacePath = `/${workspace}${bundle?.app.root_url ?? "/app"}` // /kafe/app/pos
const mountPrefix = `/${workspace}/app` // /kafe/app

Akibatnya, untuk root_url yang bukan /{ws}/app, path relatif yang benar-benar dicocokkan memasukkan segmen app/<nama-app>:

URL nyatabasePath (buildRoutes)path relatif yang dicocokkan
/kafe/app/pos/cafe-master/promos/kafe/app/posapp/pos/cafe-master/promos
/kafe/cafe-master/promos (root_url: /)/kafecafe-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.

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 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:

RouteTrail
/<module>/<plural>jejak menu ke list itu; Transaksi › Pesanan
…/:idtrail list, lalu identitas record (natural key)
…/:id/edit+ langkah Edit (menaut balik ke detail)
…/newtrail list, lalu New
Page authoredspec.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 setiap href yang 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 (store stores/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 ​

AspekNilai
Dibuat olehpenulis manifest (spec.route)
Didaftarkan dirouter.tsx blok // 1. Page routes
PathbasePath + spec.route
KomponenPageRenderer
Form dipilih olehblock.form.ref → getForm(name) → resolveForm(explicitRef)
Mode defaultblock.form.mode ?? "view"
Gerbangpage.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 ​

AspekNilai
Dibuat olehserver — makeDerivedPage (internal/ui/meta.go)
Didaftarkan dirouter.tsx lewat bundle.pages (tidak bisa dibedakan dari A)
Nama entry<nama-form>-page
Path/<module>/form/<nama> atau /<module>/table/<nama>
Form dipilih olehblock.form.ref = nama Form itu (satu-satunya)
Modeselalu view — block-nya dibuat tanpa mode

Dua gerbang yang membuat jalur B tidak selalu ada, dan keduanya harus diingat saat menautkannya:

  1. spec.public: false pada Form → derived Page dilewati.
  2. Form sudah direferensikan block.form.ref di Page lain → ditandai covered → derived Page tidak dibuat.

C. Derived entity CRUD — dibuat klien ​

AspekNilai
Dibuat olehbuildRoutes (router.tsx blok // 2.)
PathbasePath + /<module>/<plural> + [/new | /:id | /:id/edit]
KomponenTableRenderer (list), DetailPage (:id), FormRenderer (new/edit)
Form dipilih olehkonvensi resolveForm — tanpa formRef
Gerbangentity.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 ​

AspekNilai
Dipicu olehklik New/Edit di TableRenderer → setSearchParams
Kontrak query?action=create|edit + form=<nama> atau entity=<module.name> + id= + mode=
Dibaca olehOverlayHost (shell/OverlayHost.tsx)
Form dipilih olehgetForm(formName); fallback resolveForm bila hanya entity=
Modedari 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.

AspekNilai
Slotchange_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)
KomponenAuthPage — 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:

ts
// 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:

SinyalABCD
Pola URLroute authored/<module>/form|table/<n>/<module>/<plural>[/new|/:id]sama seperti A/C + ?action=
Ada file kind: Page dengan route: itu?yatidaktidakya/tidak (induknya)
Muncul di bundle.pages sebagai <n>-page?tidak (nama authored)yatidak—
FormRenderer menerima formRef?ya (kalau block pakai ref)yatidakya (dari ?form=)
mermaid
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/&lt;name&gt; (mode view)"]
  Q3 -->|tidak| C["C — derived CRUD<br/>/module/&lt;plural&gt;…"]

Contoh kafe untuk entity promo ​

URLJalurForm dipakaiMode
/kafe/app/pos/cafe-master/promosC— (Table)—
.../promos?action=create&form=promo-form&mode=drawerDpromo-form (?form=)create
.../promos/newCpromo-form (konvensi {entity}-form)create
.../promos/123/editCpromo-form (konvensi {entity}-form)edit
.../cafe-master/form/promo-formBpromo-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:

  1. explicitRef (dari form.ref / ?form=) — menang mutlak, apa pun modenya.
  2. {entity}-{mode} — promo-create / promo-edit / promo-view.
  3. {entity}-form — generik untuk semua mode.
  4. deriveForm(entity, mode) — hasil turunan dari schema entity.

Dua hal yang paling sering salah dibaca:

  • spec.mode pada Form TIDAK memilih mode runtime. FormRenderer menerima mode dari pemanggilnya (route: create/edit; block: block.form.mode, default view). promo-form yang menulis mode: edit tetap 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.ref atau 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 semacam default_form di TableSpec, 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:

KindRoute
Pagespec.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:

  1. Module.spec.menu = saran; App.spec.menu = otoritatif. Adopt node (type: module, hanya level 1) menyisipkan menu modul di posisi itu; stampModule mengisi module pada tiap leaf karena item modul ditulis module-relative. Maksimum 3 level.
  2. Leaf wajib label + module + tepat satu dari view/route (internal/app/resolve.go). view di-resolve ke route saat App di-resolve; route adalah escape hatch untuk URL mentah.
  3. filterMenu berjalan terakhir di BuildBundle, setelah entity, page, dashboard, dan widget final. Ia membuang item yang route-nya tidak dilayani bundle atau yang permissions-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 ​

PerubahanAlasan
permissions: pada item menu disaring serverRBAC 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 klienBergantung waktu (today()); bundle di-ETag-hash, jadi filter waktu di server akan membatalkan cache terus-menerus.
Listing bisa jadi target viewResolveViewRoute tidak punya cabangnya sementara viewKinds (validator) mengizinkannya — validate hijau, App gagal resolve (§8).

6. Visibilitas menu: dua sumbu, lima lapis ​

#LapisSisiBypass-ableMenahan apa
1Entity visibility (BuildBundle)server❌entity tanpa list/view tidak masuk bundle → route tak pernah didaftarkan
2filterMenu — route harus ada di bundleserver❌tautan ke route yang tidak dilayani
3MenuItem.permissions (any-of)server❌RBAC eksplisit per item
4required_permission per action + row scopeserver❌setiap panggilan data
5MenuItem.whenklien✅ memangkenyamanan UX saja

when bukan 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 memaksa when bernilai 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) melanggar 08-formspec-expr.md §3 yang melarang identitas/permission di FormSpecExpr. Item itu tampil untuk semua orang sambil terlihat dijaga. formspec check kini 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:

TempatDiperiksa
Form: visible_when/readonly_when/required_when/computereferensi field + grammar + callable
Wizard step field (sama)idem
Kanban drag_guardidem
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:

#SalinanBerkas
1ResolveViewRouteinternal/ui/registry.go
2routeExists / navigationPrefixinternal/ui/meta.go
3buildRoutessrc/shell/router.tsx
4viewKindscmd/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 ​

  1. routeExists bertanya ke daftar Form, bukan ke bundle.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.
  2. _admin buta permissions/when — SELESAI dengan dihapusnya surface _admin (plan app-scoped-login.md D4). Tidak ada lagi bundle always-true: setiap bundle App-scoped dan permission-filtered, jadi sidebar tidak mungkin menampilkan entity yang endpoint-nya akan 403.
  3. MenuItem.when tidak berlaku di _admin — Selesai bersama butir 2 (permukaan yang bersangkutan sudah tidak ada).
  4. 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.ref eksplisit.

9. Resep diagnosis — "URL ini jalur apa?" ​

  1. Lihat URL. Ada ?action=? → jalur D. Segmen akhir form/table? → jalur B. Berakhir /<plural> atau …/new atau …/:id/edit? → jalur C. Selain itu → jalur A.
  2. Konfirmasi dengan manifest.grep -rn "route: <path-relatif>" examples/<app>/spec — ada hasil berarti jalur A (file kind: Page), tidak ada berarti B atau C.
  3. Periksa bundle.
    bash
    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]}'
    Page bernama <sesuatu>-page dengan route /…/form/… = jalur B. Catatan: App publik tanpa token hanya memberi bundle kosong — perlu kredensial.
  4. Periksa argumen form. Form yang tampil sebagai halaman penuh tanpa ?form= (jalur C) memakai konvensi resolveForm; kalau tidak ada Form authored yang cocok konvensi, yang dirender adalah hasil turunan — dan itu bukan bug, melainkan fallback yang tak kelihatan.

Gejala → penyebab ​

GejalaKemungkinan penyebab
Tombol New memakai layout Form "edit">1 Form untuk entity yang sama; jalur D mengambil match pertama alfabetis
Form authored tidak dipakai di …/newnamanya tidak mengikuti konvensi {entity}-create/-edit/-form
Halaman penuh terbuka mode view padahal Form punya mode: editjalur B — derived Page dibuat tanpa mode
Item menu menuju 404derived Page disuppress (public: false / covered) — sisa §8 butir 1
view: <listing> membuat App gagal mountsudah ditutup; bila muncul lagi, periksa keempat salinan konvensi §8
when tidak menyembunyikan apa punekspresi tidak bisa dievaluasi (fail-open) → lihat console; formspec check menolaknya

10. Cross-ref ​

Standar terbuka (CC0) dengan implementasi referensi.