Hôm nay tôi vừa mở mã nguồn phiên bản đầu tiên của nền tảng marketing mình làm.
Nó tên là STRAŦUM. Chín AI agent, một workspace cho agency với dữ liệu tách riêng theo từng client, mười ngôn ngữ. Tôi xây nó năm 2025 trong lúc tự học viết phần mềm. Rồi tôi bắt tay vào phiên bản thứ hai, và thay vì giữ bản đầu cho riêng mình, tôi đã cho nó đi.
Nếu bạn muốn xem qua trước khi đọc tiếp: github.com/chandlernguyen/stratum-oss
git clone https://github.com/chandlernguyen/stratum-oss
Nếu bạn quản lý đội marketing và sẽ không bao giờ mở repository đó, hãy nhảy tới "Nếu bạn không bao giờ đọc code" ở gần cuối. Phần đó dành cho bạn, và nó ngắn thôi.
Tôi muốn nói cho rõ repository này thực ra là gì, vì "open source" có thể mang rất nhiều nghĩa, và phần lớn trong số đó nói quá về thứ đang có ở đây.
Những gì tôi thực sự công bố
STRAŦUM v1 đã được công bố và không còn được phát triển tích cực. Code công khai theo giấy phép MIT. Không có roadmap, không có lịch release, không có cam kết hỗ trợ — README nói vậy, và contributing guide nhắc lại lần nữa.
Nó được công bố như một reference implementation. Nó là thứ để đọc, để fork, hoặc để lấy ra vài phần. Nó không phải là thứ để phụ thuộc vào. Không ai duy trì nó trước những thay đổi dependency trong tương lai, và repository liệt kê thẳng những advisory đang mở mà nó biết, thay vì giả vờ như chúng không tồn tại.
Điều tôi không nói là nó sẽ không bao giờ thay đổi. Tôi không phát triển nó thêm, nên bạn hãy tính chuyện nó giữ nguyên như hiện tại — nhưng tôi vẫn đọc những gì người ta gửi, và nếu ai đó báo một bug hay góp ý thứ gì đó khiến code rõ ràng hơn, tôi sẽ không giả vờ là mình không thấy.
Công bố nó cũng có nghĩa là phải chứng minh không có gì mật bị lẫn vào. Trong repository có một script quét toàn bộ file được track để tìm credential, private key, token của provider và định danh production, và nó chạy như job đầu tiên trong CI — trước cả test, vì một key bị lộ sẽ nằm trong history ngay lúc push, và xóa nó sau đó không hoàn tác được điều đó.
Đây là những gì có trong đó:
- Chín agent — strategy, persona, content, performance intelligence, competitive intelligence, campaign planning, client success, và hai cái nữa. Mỗi agent là subclass của một base agent, dùng chung một cấu trúc prompt, một tool registry và progressive context.
- Hai schema Postgres. Dữ liệu của từng doanh nghiệp nằm ở một schema, dữ liệu agency ở schema kia, với Row Level Security làm ranh giới cách ly.
- Mười ngôn ngữ, với locale được gửi tới API qua header để metadata sinh ra khớp với ngôn ngữ giao diện.
- Một demo mode không cần API key. Các agent trả về output dựng sẵn được ghi nhãn rõ ràng thay vì gọi model, nên bạn có thể bấm qua toàn bộ ứng dụng mà không cần tài khoản provider.
Nếu bạn muốn tự xây phiên bản của riêng mình
Đây là kiểu sử dụng tôi muốn thấy nhất, nên tôi nói thẳng ra thay vì để nó ẩn trong suy đoán.
Nếu thứ bạn thật sự muốn là một hệ thống marketing agent của riêng bạn — agent của bạn, prompt của bạn, schema của bạn, sản phẩm của bạn dựng lên trên đó — thì cứ fork repository rồi xây. Giấy phép MIT sinh ra chính là để làm việc đó, và đây là mục đích dự kiến, không phải một kẽ hở. Không có yêu cầu ghi nguồn nào ngoài việc giữ lại file giấy phép, và bạn không cần hỏi tôi. Hãy lấy những phần hữu ích, bỏ đi những phần không, và thay đổi bất cứ thứ gì bạn thấy không đúng.
Tôi thà thấy một tá phiên bản khác nhau của thứ này còn hơn một phiên bản mà chỉ mình tôi từng đụng vào.
Vì sao công bố v1 thay vì giữ nó cho riêng mình
Tôi để nó nằm đó mười tháng vì nghĩ chẳng ai muốn một bản v1 mà chính tôi đã ngừng làm. Giả định đó sai, và lý do nó sai còn giá trị hơn cả repository này.
Phiên bản thứ hai đi theo hướng khác. Thứ tôi đang xây bây giờ không giống codebase này lắm, và nó sẽ không phải là một diff so với bản cũ. Giữ v1 riêng tư nghĩa là nó dần thành một hiện vật bảo tàng không ai vào xem được.
Lý do quan trọng hơn: một ứng dụng agentic đa tenant chạy được là một thứ hữu ích hơn hẳn một sơ đồ kiến trúc. Hồi tôi đang học, thứ giúp tôi không phải là bài giải thích khái niệm — mà là tìm được một dự án thật và đọc xem người khác đã thực sự sắp xếp nó thế nào. Chính phần lý lẽ bị mất đi trong bài giải thích mới là phần quan trọng nhất.
Tôi viết bốn điều trong phần ghi chú kiến trúc của repository: những thứ tôi đã làm sai và sẽ không làm lại, và tôi để nguyên cả bốn. Điều đầu tiên là một lỗi bảo mật.
Sai lầm đã ship
Một bảng được đưa ra ngoài mà không bật Row Level Security.
public.notification_push_deliveries được tạo ra mà thiếu dòng bật RLS, rồi được cấp cho các role anonymous và authenticated với toàn quyền, kể cả TRUNCATE, trong khi không có policy nào được định nghĩa. Nó nằm trong schema mà Data API phơi ra, và cái anonymous key được đóng gói ngay trong browser bundle. Trong khoảng thời gian đó, bất kỳ ai giữ key đó đều có thể đọc hoặc sửa bảng này. Bảng lưu định danh thiết bị nhận push, khiến đây là một vấn đề lộ dữ liệu thật sự chứ không chỉ mang tính lý thuyết.
Tôi tìm ra nó trong một buổi security review. Không phải từ bug report, cũng không phải khi test, vì ứng dụng chạy hoàn hảo suốt thời gian đó. Không có gì báo lỗi khi một bảng thiếu RLS. Query vẫn chạy, tính năng vẫn hoạt động, và cái quyền lẽ ra không nên tồn tại cứ nằm đó, không gây ra gì nhìn thấy được cho tới khi có người để mắt tới.
Giờ nó đã được đóng lại. RLS đã được bật trên bảng và không có policy nào được định nghĩa, còn các role browser thì đã bị thu hồi grant, nên request mà trước đây sẽ trả về row giờ trả về lỗi phân quyền. Tôi cố ý kể chuyện này ở thì quá khứ: đây là hình dạng của code ngày đó, không phải hình dạng của nó hôm nay.
Đó là chỗ tôi muốn nói cho thật rõ: đây không phải một quyết định thiết kế tôi làm đúng. Đây là một regression tôi làm sai và chỉ phát hiện sau đó.
Nên tôi ngừng tin vào trí nhớ của mình
Việc tôi làm tiếp theo là thứ duy nhất trong câu chuyện này mà tôi dám tự tin đưa cho người khác.
Tôi viết một test không gọi tên bất kỳ bảng cụ thể nào. Nó đi qua toàn bộ schema và khẳng định cái quy tắc: mọi bảng trong schema bị phơi ra đều phải bật Row Level Security, và không materialised view nào thuộc phạm vi tenant được phép cho role browser đọc. Nếu tháng sau tôi thêm một bảng rồi quên, test sẽ fail — và nó fail đúng ở thứ tôi quên, chứ không phải ở một danh sách tôi viết hồi còn nhớ.
Hai điều tôi thành thật tự rào trước, và cả hai tôi thà tự nói ra còn hơn để người đọc tự phát hiện sau:
- Phần kiểm tra materialised view chỉ bao phủ schema
public, không bao gồm schemaagency. Phía agency có một lỗ hổng riêng mà tôi chưa đóng lại. - Test tự skip khi không kết nối được database. Một test bị skip vẫn xanh, nghĩa là một môi trường hỏng có thể che mất một invariant đang bị vi phạm.
Nếu có một thói quen tôi mang theo từ tất cả chuyện này, thì là: khi gặp một bug kiểu này, tôi viết cái test lẽ ra đã bắt được nó — và tôi viết nó dựa trên quy tắc chứ không dựa trên một object cụ thể. Một test gọi tên ba object mà tôi còn nhớ chỉ bảo vệ đúng ba object đó. Một test khẳng định quy tắc sẽ bảo vệ cái tôi thêm vào tuần sau rồi quên mất.
Cái bẫy thứ hai
Có cái thứ hai mà tôi làm đúng nhờ may mắn chứ không phải nhờ thiết kế, và đây chính là điều tôi nghe lặp lại hoài trong các buổi trao đổi với vendor.
Filter phía client không phải là một ranh giới. Nếu ứng dụng của bạn lọc row theo organisation ngay trong browser — .eq('org_id', ...) và họ hàng của nó — thì việc cách ly đang chạy trên máy của người dùng, nghĩa là gỡ nó bằng developer tools được. Bất cứ thứ gì gỡ được đều chỉ là tùy chọn hiển thị, không phải biện pháp bảo mật. Việc hiển thị row phải được cưỡng chế hoặc trong database, hoặc sau một server endpoint mà người gọi không thể vòng qua.
Materialised view là người họ hàng khó xử của vấn đề này. Chúng hoàn toàn không thể có Row Level Security. Một grant SELECT trên materialised view sẽ trả về row của mọi tenant, và trong migration nó trông y hệt grant tương tự trên một bảng — nơi policy vẫn sẽ ràng buộc được. Đúng vì lý do đó, repository thu hồi grant của role browser trên mọi thứ thuộc phạm vi tenant bị materialise.
Cái tôi chưa làm xong
Row Level Security không thể giới hạn TRUNCATE. Đó là một quyền ở cấp bảng, không phải cấp row, nên policy không áp dụng cho nó. Các grant nền tảng của repository cấp cho role browser quyền ALL trên một số lượng lớn bảng — trong đó có TRUNCATE — và không có gì thu hẹp lại sau đó. Grant trên materialised view đã bị thu hồi. Còn những cái này thì chưa. Nên vẫn có những bảng mà role browser giữ một quyền nằm ngoài ranh giới mà tôi vừa dành cả phần này để mô tả.
Trên thực tế nó ở dạng tiềm ẩn chứ chưa phải một cánh cửa mở: Data API không có verb TRUNCATE, role browser không thể kết nối trực tiếp vào database, và một deployment bình thường không mở port database. Đây là vấn đề vệ sinh. Nhưng nó cùng hình dạng với cái bug ở đầu bài — một quyền lặng lẽ sống lâu hơn lý do tồn tại của nó — và tôi thà chỉ thẳng vào nó còn hơn để người đọc tự tìm ra rồi tự hỏi liệu tôi có biết không. Nó cần một lệnh revoke trên cả hai schema, và một test khẳng định rằng không role browser nào giữ quyền vượt qua được RLS.
Những gì tôi sẽ không làm lại
Hai schema song song khiến DDL bị lặp lại rất nhiều. Dữ liệu của agency và của từng doanh nghiệp có cấu trúc song song, thay vì một bảng với cột phân biệt tenant. Sự tách biệt thì thật sự sạch sẽ hơn. Nhưng chi phí bảo trì do trùng lặp cao hơn cái cột discriminator kia, và lần sau tôi sẽ chọn cách ngược lại.
Gọi function thủ công tốn rất nhiều code. Vòng lặp agent xử lý tool call bằng tay thay vì dùng automatic function calling của provider — cách này giữ streaming và việc thực thi tool nằm trong tầm kiểm soát của ứng dụng. Cái giá là ứng dụng phải tự lắp các function-result part, và với các model hiện tại, những part đó phải mang cả call id lẫn tên function. Bỏ sót id thì bạn không nhận được lỗi schema. Bạn nhận được một thứ đọc như thể model chập chờn — một buổi chiều tệ hơn nhiều. Nếu giờ đã có API cấp cao hơn cho streaming kèm tool, đánh đổi này đáng để xem lại.
Chuỗi migration lên tới 321 file. Một phần lớn trong đó mang tên fix_, _v2 và remove_, vì tôi cứ nối thêm bản sửa thay vì sửa thẳng thứ mình đã viết. Chỉ có thể biết trạng thái thật của schema bằng cách phát lại toàn bộ history từ đầu. Những bài tôi viết hồi đó tự tin về giai đoạn này hơn mức mà code xứng đáng — tháng 11 năm 2025 tôi nói ba mươi ba migration đã "giải quyết xong" multi-tenancy, rồi sau đó vẫn viết thêm migration sửa lỗi suốt nhiều tháng.
Frontend tin vào shape của API mà không validate. Chiều vào thì validate rất kỹ — backend parse mọi request bằng Pydantic — còn chiều ra thì không có gì. Browser nhận response và tin luôn. Một schema dùng chung sẽ bắt được lúc dữ liệu lệch đi khi một field đổi shape, thay vì để nó lộ ra dưới dạng một màn hình trắng. Cộng với ba điều trên, đó là bốn thứ mà phần ghi chú kiến trúc nói rằng tôi sẽ làm khác.
Repository được công bố dựng lại chuỗi migration thành hai mươi migration xếp lớp, nhóm theo mối quan tâm — bảng, rồi function nhóm theo domain, view, index, trigger, policy, grant, scheduled job, và một lượt hardening cuối. Điều đó đúng ở mức gần đúng chứ không chính xác tuyệt đối, và đáng để nói thẳng chỗ nó lệch: một trong hai mươi file là chỗ gom được thừa nhận thẳng thắn cho những function không xếp loại được, và tầng bảng không được chia đúng như tên file gợi ý. File mang tên schema dùng chung cũng tạo luôn tám bảng của agency, còn file mang tên schema agency thì không chứa định nghĩa bảng nào cả. Việc xếp lớp là thật; cái nhãn thì không hoàn hảo.
Điều mà cách chia này làm đúng là phần quan trọng nhất khi đọc nó: schema là một lần tổ chức lại thuần túy, đã được kiểm chứng bằng cách dump trước và sau rồi xác nhận hai bản giống nhau từng byte, chỉ khác cái token ngẫu nhiên của công cụ dump.
Chính sự kiểm chứng đó là lý do duy nhất khiến tôi dám động vào nó.
Những gì còn trụ lại
Row Level Security là ranh giới thật sự, chứ không phải một tính năng để bật lên. Một khi thiết kế được dựng xoay quanh nó, cách ly không còn là một dòng trong checklist nữa mà trở thành thuộc tính của cả hệ thống.
Các thao tác ghi đều đi qua function database có định tuyến. Code ứng dụng không tự chọn schema để đụng vào. Nó gọi một function kiểm tra loại organisation rồi điều hướng, nên quyết định nằm ở một chỗ duy nhất mà một code path mới không thể quên.
Đăng ký không quan tâm input từ client. Trigger provisioning không chấp nhận organisation id hay role do client gửi lên, vì dữ liệu đó nằm trong tay người dùng. Nó tạo một organisation mới và một role owner mặc định. Chuyện nhỏ, dễ làm sai, và tốn kém khi sai.
Khởi tạo client theo kiểu lazy. Các service tạo provider client ở lần dùng đầu tiên thay vì ngay lúc import. Nghe như sở thích về style, nhưng không phải: vài service được tạo ngay khi import module, nên khởi tạo kiểu eager đồng nghĩa với việc import ứng dụng là đã cần API key, và lỗi hiện ra dưới dạng một thông báo mơ hồ từ SDK của provider trước khi bất cứ thứ gì kịp chạy. Làm cho nó lazy chính là thứ khiến demo mode khả thi — và đó là thứ cho phép một người lạ đánh giá dự án mà không tốn xu nào.
Điều cuối cùng là quyết định tôi hài lòng nhất, và tôi đã không đưa ra nó vì lý do khiến nó trở nên quan trọng. Tôi làm chỉ để chặn cái crash.
Nếu bạn không bao giờ đọc code
Đây là phần mà tôi từng ước có, hồi còn ngồi phía agency đi mua loại phần mềm này — nên trong đó không có code.
Điều đáng mang theo là sự khác nhau giữa "chúng tôi lọc theo account" và "database không thể trả về row của account khác."
Tôi đã đi mua platform suốt phần lớn sự nghiệp và mấy năm gần đây thì tự xây chúng, và đó chính là khác biệt tôi không hiểu cho tới khi ship nhầm phiên bản. Một cái là quy tắc mà browser tuân theo. Cái kia là quy tắc mà browser không thể phá. Phần lớn công cụ có cái thứ nhất nhưng mô tả nó bằng ngôn ngữ của cái thứ hai.
Khi bạn đang đánh giá một platform sẽ chứa dữ liệu của nhiều client — competitive intelligence, dữ liệu performance, định nghĩa audience, gì cũng được — thì câu hỏi không phải là "nó có an toàn không". Câu đó ai cũng trả lời có. Câu hỏi là: việc tách biệt được cưỡng chế ở đâu, và điều gì xảy ra nếu một developer gỡ cái filter đi?
Có hai câu trả lời tốt. Hoặc nó được cưỡng chế trong database bằng policy gắn với danh tính của người dùng đã đăng nhập, hoặc nó được cưỡng chế sau một server endpoint mà browser không thể vòng qua. Mọi câu trả lời có dính tới browser đều là không.
Câu trả lời tốt nghe như: policy nằm trên bảng và gắn với session của người dùng; browser không bao giờ query trực tiếp bảng đó; đây là test chứng minh điều đó. Câu trả lời ít hữu ích hơn nghe như: ứng dụng của chúng tôi lọc theo account; dữ liệu được mã hóa; chúng tôi đạt chuẩn SOC 2. Những điều đó có thể đều đúng, và không điều nào trả lời câu hỏi.
Chỉ một dòng thôi, và nó nằm gọn trong một buổi vendor security review — đó là chỗ tôi sẽ đặt nó. Câu trả lời sẽ cho bạn biết multi-tenancy được thiết kế từ đầu hay được gắn thêm sau.
Câu hỏi thường gặp
Vì sao công bố nó thay vì để nó nằm trong một repository riêng tư?
Vì một thứ công khai thì kiểm chứng được, còn thứ riêng tư thì không. Những bài tôi viết trước đây về quá trình xây nó chỉ đưa ra các tuyên bố; còn một repository với migration, policy và test là thứ người đọc tự kiểm được, kể cả những chỗ tôi làm sai. Phần ghi chú kiến trúc có một mục tên là "Tradeoffs, và những gì tôi sẽ làm khác đi", và đó chính là lý do repository tồn tại ở hình dạng này.
Phiên bản thứ hai có open source không?
Không. Nó được phát triển riêng tư, và codebase dự kiến sẽ không giống bản này. Tôi thà nói thẳng như vậy còn hơn để mập mờ rồi người ta clone về với kỳ vọng có roadmap.
Tôi có thể dùng nó trong production không?
Tôi thì không. Nó là tài liệu tham chiếu, không phải sản phẩm. Thông tin đăng nhập demo trong seed data chỉ dùng cho môi trường local, không có cam kết hỗ trợ, và không ai sửa nó trước những thay đổi dependency trong tương lai. Nó là thứ tốt để đọc và để mượn ý — và là thứ tệ để vận hành một doanh nghiệp trên đó.
Tôi có cần API key AI để thử không?
Không. Nó khởi động ở demo mode, nơi các agent trả về output dựng sẵn được ghi nhãn rõ ràng thay vì gọi model. Bạn có thể bấm qua toàn bộ ứng dụng — từng agent, luồng client của agency, nút chuyển ngôn ngữ — mà không cần key và không tốn gì. Chuyển sang gọi model thật chỉ là một dòng cấu hình và một cái key. Lần đầu chạy, tôi mất hơn mười phút: nó cần Docker đang chạy, Node, Python, Poetry và Supabase CLI, và phần chậm là một lượt tải dung lượng lớn.
Tôi viết khá nhiều về quá trình xây nó ngay trong lúc đang xây, và hai bài tôi muốn bạn bắt đầu là vì sao tôi xây multi-tenancy ngay ngày thứ hai và chuyện gì xảy ra khi tôi xây lại nó vào ngày thứ sáu mươi bảy. Nếu chỉ một bài đáng để bạn bỏ thời gian, thì đó là bài thứ hai — bài mà kiến trúc hóa ra sai.
Code nằm ở github.com/chandlernguyen/stratum-oss, và cái test canh chừng sai lầm tôi đã ship nằm trong tests/automated/test_rls_coverage.py.
Nếu bạn từng ship một hệ thống đa tenant và phát hiện cái bẫy thầm lặng thứ ba mà tôi chưa nhắc tới, tôi thật sự muốn nghe — đó mới là những thứ đáng để sưu tầm.
Feedback, góp ý và chuyện gì xảy ra với v1
Tôi không muốn đây là một chiều, nên đây là phiên bản thành thật về những gì bạn có thể mong đợi.
Những gì tôi hoan nghênh: bug report nếu trong repository có thứ gì đó đơn giản là sai. Góp ý về những chỗ có thể rõ ràng hơn, đơn giản hơn, hoặc làm bằng ít code hơn. Ghi chú từ những ai đã thử chạy nó và gặp thứ mà README không nói tới. Pull request, nếu bạn tìm ra một vấn đề thật và muốn sửa. Và nếu bạn fork nó rồi xây thứ gì đó của riêng mình, tôi muốn biết bạn đã đổi gì và vì sao — đó là loại feedback thú vị nhất, vì bạn phải thật sự tự ra quyết định.
Những gì tôi có thể hứa: không nhiều, và tôi thà nói thẳng như vậy còn hơn để bạn hiểu khác. Đây không phải một dự án đang được phát triển tích cực, tôi không vận hành một bàn hỗ trợ, và tôi không thể cam kết thời gian phản hồi. Có góp ý tôi sẽ làm. Có góp ý tôi sẽ đọc, thấy đúng, rồi không bao giờ có thời gian làm. Đó là phiên bản thực tế của một side project đã có bản kế nhiệm.
Chuyện gì xảy ra với v1: nó vẫn được công bố nguyên như vậy. Tôi không phát triển nó thêm, nên đừng tính toán dựa trên các bản release mới. Nhưng tôi cũng không giả vờ rằng nó đã bị niêm phong — code công khai, giấy phép cho phép bạn đưa nó đi bất kỳ hướng nào, và nếu có thứ gì đó hỏng hoặc thật sự không rõ ràng, thì chẳng có lý do chính đáng nào để tôi cứ mặc kệ nó.
Cách đơn giản nhất để liên hệ với tôi là mở issue trên repository, hoặc email nếu bạn không muốn công khai.
Vậy thôi, hết rồi.
Thân mến, Chandler