Hari ini saya merilis versi pertama platform marketing saya sebagai open source.
Namanya STRAŦUM. Sembilan agen AI, sebuah workspace agensi dengan pemisahan data per klien, sepuluh bahasa. Saya membangunnya pada 2025 sambil belajar menulis software sendiri. Lalu saya mulai mengerjakan versi kedua, dan alih-alih menyimpan versi pertama rapat-rapat, saya memberikannya.
Kalau kamu mau lihat dulu sebelum membaca sisanya: github.com/chandlernguyen/stratum-oss
git clone https://github.com/chandlernguyen/stratum-oss
Kalau kamu memimpin tim marketing dan tidak akan pernah membuka repositori itu, lompat ke "Kalau kamu tidak akan pernah membaca kodenya" di dekat bagian akhir. Bagian itu untukmu dan isinya singkat.
Saya ingin tepat soal apa sebenarnya repositori itu, karena "open source" bisa berarti banyak hal dan sebagian besar di antaranya melebih-lebihkan apa yang sebenarnya ini.
Apa yang sebenarnya saya publikasikan
STRAŦUM v1 sudah dipublikasikan dan tidak lagi dikembangkan secara aktif. Kodenya publik di bawah lisensi MIT. Tidak ada roadmap, tidak ada jadwal rilis, dan tidak ada komitmen dukungan — README-nya bilang begitu, dan panduan kontribusinya mengulanginya.
Proyek ini dipublikasikan sebagai reference implementation. Ini sesuatu untuk dibaca, untuk di-fork, atau untuk diambil sebagian isinya. Ini bukan sesuatu untuk dijadikan sandaran. Tidak ada yang memeliharanya terhadap perubahan dependensi di masa depan, dan repositori itu mencantumkan advisory terbuka yang diketahuinya, bukan berpura-pura semuanya tidak ada.
Yang tidak saya katakan adalah bahwa ini tidak akan pernah berubah. Saya tidak mengembangkannya lebih jauh, jadi sebaiknya kamu berencana seolah-olah keadaannya tetap seperti sekarang — tapi saya membaca apa yang orang kirimkan ke saya, dan kalau ada yang melaporkan bug atau mengusulkan sesuatu yang membuat kodenya lebih jelas, saya tidak akan berpura-pura tidak melihatnya.
Mempublikasikannya juga berarti membuktikan tidak ada yang bersifat rahasia ikut terbawa. Ada script di repositori itu yang memindai setiap file yang dilacak untuk mencari kredensial, private key, token provider, dan identifier produksi, dan script itu berjalan sebagai job pertama di CI — sebelum tes, karena key yang bocor sudah masuk ke history begitu di-push, dan menghapusnya setelah itu tidak membatalkan hal tersebut.
Ini isinya:
- Sembilan agen — strategi, persona, konten, performance intelligence, competitive intelligence, perencanaan kampanye, client success, dan dua lagi. Masing-masing adalah subclass dari satu base agent, dengan struktur prompt, tool registry, dan progressive context yang sama.
- Dua schema Postgres. Data tiap bisnis individual ada di satu schema, data agensi di schema lain, dengan Row Level Security sebagai batas isolasinya.
- Sepuluh locale, dengan locale-nya ikut terkirim ke API lewat header supaya metadata yang dihasilkan cocok dengan bahasa antarmuka.
- Mode demo yang tidak butuh API key. Agen-agennya mengembalikan output siap pakai berlabel jelas alih-alih memanggil model, jadi kamu bisa menelusuri seluruh aplikasi tanpa akun provider.
Kalau kamu ingin membangun versimu sendiri dari ini
Ini penggunaan yang paling ingin saya lihat, jadi lebih baik saya katakan terus terang daripada membiarkannya tersirat.
Kalau yang sebenarnya kamu inginkan adalah sistem agen marketing-mu sendiri — agenmu sendiri, prompt-mu sendiri, schema-mu sendiri, produkmu sendiri di atasnya — maka fork repositorinya dan bangun. Untuk itulah lisensi MIT ada, dan itu penggunaan yang dimaksudkan, bukan celah. Tidak ada kewajiban atribusi selain mempertahankan file lisensinya, dan tidak perlu bertanya ke saya. Ambil bagian yang berguna, buang bagian yang tidak, dan ubah apa pun yang tidak kamu setujui.
Saya lebih suka melihat selusin versi berbeda dari ini daripada satu versi yang hanya saya sendiri yang pernah menyentuhnya.
Kenapa mempublikasikan v1 daripada menyimpannya untuk diri sendiri
Saya mendiamkannya selama sepuluh bulan karena saya mengira tidak ada yang mau v1 yang sudah saya tinggalkan. Anggapan itu salah, dan alasan mengapa anggapan itu salah lebih berharga daripada repositorinya sendiri.
Versi kedua mengambil arah yang berbeda. Yang saya bangun sekarang tidak terlalu mirip dengan codebase ini, dan tidak akan berupa diff terhadapnya. Menyimpan v1 tetap privat berarti v1 perlahan jadi barang museum yang tidak bisa dikunjungi siapa pun.
Alasan yang lebih baik: aplikasi agentic multi-tenant yang benar-benar berjalan adalah artefak yang lebih berguna daripada diagram arsitektur. Waktu saya belajar, yang membantu bukanlah penjelasan konseptualnya — melainkan menemukan proyek nyata dan membaca bagaimana orang lain benar-benar menatanya. Penalaran yang hilang dalam penjelasan konseptual justru penalaran yang paling penting.
Saya menulis empat hal di catatan arsitektur repositori itu yang saya salah dan tidak akan saya ulangi, dan keempatnya saya biarkan. Yang pertama adalah bug keamanan.
Kesalahan yang terlanjur dirilis
Satu tabel dirilis tanpa Row Level Security diaktifkan.
public.notification_push_deliveries dibuat tanpa baris yang mengaktifkan RLS, lalu diberikan ke role anonymous dan authenticated dengan hak akses penuh — termasuk TRUNCATE — tanpa satu pun policy yang didefinisikan. Tabel itu berada di schema yang diekspos Data API, dan anonymous key ikut terkirim di dalam bundle browser. Jadi pada periode itu, siapa pun yang memegang key tersebut bisa membaca tabel itu atau mengubahnya. Tabel itu menyimpan identifier perangkat push, yang membuatnya jadi masalah kebocoran data, bukan sesuatu yang teoretis.
Saya menemukannya dalam sebuah security review. Bukan dari bug report, dan bukan dari pengujian, karena aplikasinya berjalan sempurna sepanjang waktu. Tidak ada yang melempar error saat sebuah tabel tidak punya RLS. Query-nya berhasil, fiturnya jalan, dan izin yang seharusnya tidak ada itu sekadar ada di sana, tidak melakukan apa pun yang terlihat sampai ada yang memeriksanya.
Sekarang sudah ditutup. RLS aktif di tabel itu tanpa policy apa pun, dan grant untuk role browser sudah dicabut, jadi request yang dulu akan mengembalikan baris sekarang balik sebagai error izin. Saya sengaja menuliskannya dalam bentuk lampau: seperti inilah kode itu dulu, bukan seperti keadaannya hari ini.
Itu bagian yang ingin saya sampaikan dengan tepat: ini bukan keputusan desain yang saya buat dengan benar. Ini regresi yang saya salah dan baru saya temukan setelahnya.
Jadi saya berhenti memercayai ingatan saya
Yang saya lakukan berikutnya adalah satu-satunya bagian dari ini yang berani saya serahkan ke orang lain dengan yakin.
Saya menulis tes yang tidak menyebut tabel tertentu mana pun. Tes itu menyusuri schema dan memastikan aturannya: setiap tabel di schema yang diekspos punya Row Level Security aktif, dan tidak ada materialized view berlingkup tenant yang bisa dibaca oleh role browser. Kalau bulan depan saya menambah tabel dan lupa, tesnya gagal, dan gagal justru pada hal yang saya lupakan, bukan pada daftar yang saya tulis saat saya masih ingat.
Dua catatan jujur, dan saya justru ingin pembaca menagih keduanya ke saya daripada menemukannya sendiri belakangan:
- Bagian materialized view dari pemeriksaan itu mencakup schema
public, bukan schemaagency. Sisi agency punya celahnya sendiri yang belum saya tutup. - Tesnya men-skip dirinya sendiri saat tidak bisa menjangkau database. Tes yang di-skip tetap hijau, artinya lingkungan yang rusak bisa menyembunyikan invariant yang sedang gagal.
Kalau ada satu kebiasaan yang saya ambil dari semua ini, itulah dia: saat menemukan bug seperti ini, saya menulis tes yang seharusnya menangkapnya — dan saya menulisnya terhadap aturan, bukan terhadap objeknya. Tes yang menyebut tiga objek yang saya ingat hanya melindungi ketiga objek itu. Tes yang memastikan aturannya melindungi objek yang saya tambahkan minggu depan lalu saya lupakan.
Jebakan kedua
Ada jebakan kedua yang saya lakukan dengan benar karena keberuntungan, bukan karena desain, dan itulah yang sering saya dengar diulang dalam percakapan dengan vendor.
Filter di sisi klien bukanlah batas. Kalau aplikasimu memfilter baris berdasarkan organisasi di browser — .eq('org_id', ...) dan kawan-kawannya — maka isolasinya berjalan di mesin pengguna, yang berarti bisa dihapus dengan developer tools. Apa pun yang bisa dihapus hanyalah preferensi tampilan, bukan kontrol keamanan. Visibilitas baris harus ditegakkan di database atau di balik endpoint server yang tidak bisa dilewati pemanggilnya.
Materialized view adalah kerabat yang merepotkan dari masalah ini. Materialized view sama sekali tidak bisa punya Row Level Security. Grant SELECT pada materialized view mengembalikan baris semua tenant, dan di dalam migration bentuknya identik dengan grant yang sama pada tabel, di mana policy masih akan membatasinya. Karena alasan itulah repositori itu mencabut grant untuk role browser pada apa pun berlingkup tenant yang dimaterialisasi.
Yang belum saya selesaikan
Row Level Security tidak bisa membatasi TRUNCATE. Itu hak istimewa tingkat tabel, bukan tingkat baris, jadi policy tidak berlaku untuknya. Grant dasar di repositori itu memberi role browser ALL pada sejumlah besar tabel — yang mencakup TRUNCATE — dan tidak ada yang mempersempitnya setelah itu. Grant untuk materialized view dicabut. Yang ini tidak. Jadi ada tabel di mana role browser memegang hak istimewa yang berada di luar batas yang baru saja saya jelaskan sepanjang bagian ini.
Dalam praktiknya ini laten, bukan pintu terbuka: Data API tidak punya verb TRUNCATE, role browser tidak bisa terhubung langsung ke database, dan deployment normal tidak mengekspos port database. Ini masalah higiene. Tapi bentuknya sama dengan bug di awal tulisan ini — hak istimewa yang diam-diam bertahan melewati alasan yang membenarkannya — dan saya lebih suka menunjuknya daripada membiarkan pembaca menemukannya lalu bertanya-tanya apakah saya tahu. Ini butuh revoke di kedua schema, dan satu tes yang memastikan tidak ada role browser yang memegang hak istimewa yang melewati RLS.
Yang tidak akan saya lakukan lagi
Dua schema paralel menduplikasi banyak DDL. Data agency dan data bisnis individual punya struktur paralel alih-alih satu tabel dengan kolom discriminator tenant. Pemisahannya memang benar-benar lebih bersih. Duplikasinya memakan biaya maintenance lebih besar daripada yang akan dikeluarkan kolom discriminator, dan lain kali saya akan mengambil keputusan yang sebaliknya.
Manual function calling itu banyak kodenya. Loop agen menangani tool call dengan tangan alih-alih memakai automatic function calling dari provider, yang menjaga streaming dan eksekusi tool tetap di bawah kendali aplikasi. Biayanya, aplikasi harus merakit sendiri bagian hasil fungsinya, dan pada model-model saat ini bagian itu harus membawa call id sekaligus nama fungsinya. Hilangkan id-nya dan kamu tidak mendapat error schema. Yang kamu dapat adalah sesuatu yang terbaca seperti model yang tidak konsisten, yang artinya sore yang jauh lebih buruk. Kalau streaming dengan tools sekarang tersedia lewat API tingkat yang lebih tinggi, pertukaran itu layak ditinjau ulang.
Rantai migration-nya sampai 321 file. Sebagian besar diberi nama fix_, _v2, dan remove_, karena saya menambahkan koreksi alih-alih mengedit hal yang sudah saya tulis. Keadaan schema yang sebenarnya hanya bisa diketahui dengan memutar ulang history dari awal. Tulisan-tulisan saya saat itu lebih percaya diri tentang periode tersebut daripada yang pantas didapat kodenya — pada November 2025 saya bilang tiga puluh tiga migration telah "akhirnya menyelesaikan" multi-tenancy, lalu saya terus menulis migration korektif selama berbulan-bulan setelahnya.
Frontend-nya memercayai bentuk data dari API tanpa memvalidasinya. Ada validasi yang cermat di jalur masuk — backend mem-parsing setiap request dengan Pydantic — dan tidak ada sama sekali di jalur keluar. Browser menerima responsnya dan langsung memercayainya. Shared schema akan menangkap pergeseran saat bentuk sebuah field berubah, alih-alih membiarkannya ditemukan sebagai layar kosong. Digabung dengan tiga hal di atas, itulah empat hal yang menurut catatan arsitektur akan saya lakukan secara berbeda.
Repositori yang dipublikasikan membangun ulang rantai migration-nya menjadi dua puluh migration berlapis, dikelompokkan per urusan — tabel, lalu fungsi yang dikelompokkan per domain, view, index, trigger, policy, grant, job terjadwal, dan satu pass hardening di akhir. Itu kira-kira benar, bukan benar persis, dan ada baiknya jujur soal di mana melesetnya: satu dari dua puluh itu adalah catch-all yang diakui untuk fungsi yang tidak masuk kategori mana pun, dan lapisan tabelnya tidak terbagi seperti yang disarankan nama file-nya. File yang dinamai untuk schema bersama juga membuat delapan tabel agency, dan file yang dinamai untuk schema agency tidak berisi definisi tabel sama sekali. Pelapisannya nyata; labelnya tidak sempurna.
Yang justru benar dari pemecahan ini adalah bagian yang penting untuk membacanya: schema-nya murni reorganisasi, diverifikasi dengan melakukan dump sebelum dan sesudah lalu memastikan keduanya identik byte per byte selain token acak dari alat dump-nya.
Verifikasi itulah satu-satunya alasan saya bersedia menyentuhnya sama sekali.
Yang bertahan
Row Level Security sebagai batas yang sebenarnya, bukan sekadar fitur yang tinggal dinyalakan. Begitu desainnya disusun di sekitarnya, isolasi berhenti jadi item checklist dan menjadi properti sistem.
Penulisan data lewat fungsi database yang dirutekan. Kode aplikasi tidak memilih schema mana yang disentuh. Kode memanggil fungsi yang memeriksa tipe organisasi lalu mengarahkannya, jadi pilihannya berada di satu tempat yang tidak bisa dilupakan oleh jalur kode baru.
Signup mengabaikan input dari klien. Trigger provisioning tidak menuruti organisation id atau role yang dikirim klien, karena data itu dikendalikan pengguna. Trigger membangun organisasi baru dan role owner default. Hal kecil, mudah salah, mahal saat salah.
Konstruksi client yang lazy. Service membangun client provider-nya saat pertama dipakai, bukan saat import. Kedengarannya seperti preferensi gaya, dan bukan: beberapa service dibuat saat import module, jadi konstruksi yang eager membuat mengimpor aplikasinya saja sudah butuh API key, dan kegagalannya muncul sebagai error buram dari SDK provider sebelum apa pun bisa mulai berjalan. Membuatnya lazy adalah yang memungkinkan mode demo, dan mode demo itulah yang membuat orang asing bisa mengevaluasi proyeknya tanpa biaya.
Yang terakhir itu keputusan yang paling saya banggakan, dan saya tidak mengambilnya karena alasan yang ternyata jadi penting. Saya mengambilnya untuk menghentikan crash-nya.
Kalau kamu tidak akan pernah membaca kodenya
Ini bagian yang dulu saya inginkan saat berada di sisi agensi yang membeli software semacam ini, jadi tidak ada kode di dalamnya.
Yang layak dibawa pulang adalah perbedaan antara "kami memfilter berdasarkan akun" dan "database tidak bisa mengembalikan baris milik akun lain."
Saya menghabiskan sebagian besar karier membeli platform dan beberapa tahun terakhir membangunnya, dan pembedaan itulah yang tidak saya pahami sampai saya merilis versi yang salah darinya. Yang satu adalah aturan yang diikuti browser. Yang lain adalah aturan yang tidak bisa dilanggar browser. Sebagian besar tool punya yang pertama dan menggambarkannya dengan bahasa yang kedua.
Saat mengevaluasi platform yang akan menyimpan data beberapa klien — competitive intelligence, data performa, definisi audiens, apa pun itu — pertanyaan yang perlu diajukan bukan "apakah aman". Semua orang akan bilang ya untuk itu. Pertanyaannya: di mana pemisahannya ditegakkan, dan apa yang terjadi kalau seorang developer menghapus filternya?
Ada dua jawaban yang bagus. Entah ditegakkan di database lewat policy yang terikat pada identitas pengguna yang sedang login, atau ditegakkan di balik endpoint server yang tidak bisa dilewati browser. Jawaban apa pun yang melibatkan browser berarti tidak.
Jawaban bagus terdengar seperti: policy-nya ada di tabel dan dikaitkan dengan sesi pengguna; browser tidak pernah meng-query tabel itu secara langsung; ini tes yang membuktikannya. Jawaban yang kurang berguna terdengar seperti: aplikasi kami memfilter berdasarkan akun; datanya terenkripsi; kami SOC 2 compliant. Semuanya bisa benar dan tidak satu pun menjawab pertanyaannya.
Ini cuma satu baris, dan tempatnya memang di vendor security review — di situ saya akan menaruhnya. Jawabannya memberitahu kamu apakah multi-tenancy dirancang sejak awal atau ditambahkan belakangan.
Pertanyaan yang Sering Diajukan
Kenapa dipublikasikan daripada dibiarkan di repositori privat?
Karena artefak publik bisa diperiksa dan yang privat tidak. Tulisan-tulisan saya sebelumnya tentang membangun ini berisi klaim; repositori dengan migration, policy, dan tes adalah sesuatu yang bisa diverifikasi pembaca, termasuk bagian yang saya salah. Catatan arsitekturnya punya bagian bernama "Tradeoffs, and what I would do differently", dan itulah alasan repositori ini ada dalam bentuk seperti sekarang.
Apakah versi keduanya akan open source?
Tidak. Versi itu dikembangkan secara privat, dan codebase-nya tidak diharapkan mirip dengan yang ini. Saya lebih suka mengatakannya terus terang daripada membiarkannya ambigu dan membuat orang meng-clone ini dengan harapan ada roadmap.
Bolehkah saya memakainya di production?
Saya tidak akan memakainya. Ini referensi, bukan produk. Kredensial demo di data seed hanya untuk pemakaian lokal, tidak ada komitmen dukungan, dan tidak ada yang memperbaikinya terhadap perubahan dependensi di masa depan. Ini bagus untuk dibaca dan diambil idenya, tapi buruk untuk dijadikan dasar menjalankan bisnis.
Apakah saya butuh AI key untuk mencobanya?
Tidak. Aplikasinya mulai dalam mode demo, di mana agen-agennya mengembalikan output siap pakai berlabel jelas alih-alih memanggil model. Kamu bisa menelusuri seluruh aplikasi — setiap agen, alur klien agensi, pengalih bahasa — tanpa key dan tanpa mengeluarkan biaya. Beralih ke panggilan model sungguhan cuma perlu satu pengaturan dan satu key. Tapi percobaan pertama saya memakan waktu lebih dari sepuluh menit: butuh Docker berjalan, Node, Python, Poetry, dan Supabase CLI, dan bagian yang lambat adalah unduhan besar.
Saya banyak menulis tentang membangun ini sambil mengerjakannya, dan dua tulisan yang akan saya mulai duluan adalah kenapa saya membangun multi-tenancy di hari kedua dan apa yang terjadi saat saya membangunnya ulang di hari keenam puluh tujuh. Kalau cuma satu yang sepadan dengan waktumu, itu yang kedua — di situlah arsitekturnya ternyata salah.
Kodenya ada di github.com/chandlernguyen/stratum-oss, dan tes yang menjaga kesalahan yang saya rilis ada di tests/automated/test_rls_coverage.py.
Kalau kamu pernah merilis sistem multi-tenant dan menemukan jebakan senyap ketiga yang tidak saya sebutkan, saya benar-benar ingin mendengarnya — jebakan-jebakan seperti itulah yang layak dikumpulkan.
Masukan, saran, dan apa yang terjadi pada v1
Saya lebih suka ini bukan siaran satu arah, jadi inilah versi jujurnya tentang apa yang bisa kamu harapkan.
Yang akan saya sambut baik: laporan bug kalau ada sesuatu di repositori yang memang salah. Saran soal bagian yang bisa lebih jelas, atau lebih sederhana, atau dikerjakan dengan lebih sedikit kode. Catatan dari siapa pun yang sudah mencoba menjalankannya dan menemukan hal yang tidak dicakup README. Pull request, kalau kamu menemukan masalah nyata dan ingin memperbaikinya. Dan kalau kamu mem-fork-nya dan membangun sesuatu milikmu sendiri, saya ingin tahu apa yang kamu ubah dan kenapa — itu masukan yang paling menarik dari semuanya, karena kamu harus mengambil keputusannya sungguhan.
Yang bisa saya janjikan: tidak banyak, dan saya lebih suka mengatakannya daripada memberi kesan sebaliknya. Ini bukan proyek yang dikembangkan secara aktif, saya tidak menjalankan meja dukungan, dan saya tidak bisa menjanjikan waktu respons. Sebagian saran akan saya kerjakan. Sebagian akan saya baca, setujui, dan tidak pernah sempat saya kerjakan. Itulah versi realistis dari proyek sampingan yang sudah punya penerus.
Apa yang terjadi pada v1: v1 tetap dipublikasikan seperti apa adanya. Saya tidak mengembangkannya lebih jauh, jadi jangan berencana berdasarkan rilis baru. Tapi saya tidak akan berpura-pura ini tersegel — kodenya publik, lisensinya memungkinkan kamu membawanya ke arah mana pun, dan kalau ada yang rusak atau benar-benar tidak jelas, tidak ada alasan baik bagi saya untuk membiarkannya begitu demi prinsip.
Cara paling sederhana untuk menghubungi saya adalah lewat issue di repositori, atau email kalau kamu lebih suka tidak publik.
Sekian dulu dari saya.
Salam hangat, Chandler