今日、自分のマーケティングプラットフォームの最初のバージョンをオープンソースにしました。
名前はSTRAŦUM(ストラタム)です。9つのAIエージェント、クライアントごとにデータを分離するエージェンシーワークスペース、10言語。2025年、ソフトウェアの書き方を独学していたときに作りました。それから2つ目のバージョンに着手し、最初のバージョンをしまい込んでおくのではなく、手放しました。
先に中身を見たい方は、こちらからどうぞ:github.com/chandlernguyen/stratum-oss
git clone https://github.com/chandlernguyen/stratum-oss
マーケティングチームを率いていて、このリポジトリを開く気がまったくない方は、最後のほうにある「コードを読むつもりのない方へ」まで飛ばしてください。そこはそういう方に向けた短いセクションです。
このリポジトリが何なのかについては、正確に言っておきたいと思います。オープンソースという言葉はいろいろな意味で使われますし、その大半は、これの実態を大げさに言い過ぎています。
実際に公開したもの
STRAŦUM v1は公開されていて、積極的な開発は行っていません。コードはMITライセンスで公開されています。ロードマップもリリース計画も、サポートの約束もありません——READMEにそう書いてありますし、コントリビューションガイドにも繰り返し書いてあります。
リファレンス実装として公開しています。読むため、フォークするため、あるいは一部だけ持ち出すためのものです。依存するためのものではありません。将来の依存関係の変化に合わせてメンテナンスする人はいませんし、リポジトリは、把握している未解決のアドバイザリを、存在しないふりをせずにきちんと列挙しています。
変わることは絶対にない、と言っているわけではありません。これ以上開発はしませんので、そのまま変わらないものとして考えておいてください——ただ、送られてきたものには目を通しますし、誰かがバグを報告してくれたり、コードがもっと明確になる提案をしてくれたりしたら、見なかったことにはしません。
公開するということは、機密情報が一緒に出ていないことを証明する作業でもありました。リポジトリには、追跡対象のすべてのファイルを認証情報、秘密鍵、プロバイダーのトークン、本番環境の識別子についてスキャンするスクリプトがあり、CIの最初のジョブとして動きます——テストより前です。漏れたキーはプッシュした瞬間に履歴に入ってしまい、後から削除してもそれは取り消せないからです。
中身は次のとおりです。
- 9つのエージェント — 戦略、ペルソナ、コンテンツ、パフォーマンスインテリジェンス、競合インテリジェンス、キャンペーン企画、クライアントサクセス、あと2つ。それぞれがベースエージェントのサブクラスで、プロンプト構造、ツールレジストリ、段階的コンテキストを共有しています。
- 2つのPostgresスキーマ。 個別事業者のデータは一方に、エージェンシーのデータはもう一方に入り、Row Level Security(RLS)が分離の境界になります。
- 10のロケール。 ロケールはヘッダーでAPIに渡され、生成されるメタデータがインターフェースの言語と一致するようになっています。
- APIキーが不要なデモモード。 エージェントはモデルを呼び出さず、明確にラベル付けされた定型出力を返すので、プロバイダーのアカウントがなくてもアプリ全体をクリックして回れます。
自分のバージョンを作りたい方へ
これが、私がいちばん見てみたい使い方です。暗に匂わせておくより、はっきり書いておく価値があります。
もし本当に欲しいのが自分専用のマーケティングエージェントシステム——自分のエージェント、自分のプロンプト、自分のスキーマ、その上に載る自分のプロダクト——なら、リポジトリをフォークして作ってください。MITライセンスはそのためのもので、抜け道ではなく想定された使い方です。ライセンスファイルを残す以外に帰属表示の義務はありませんし、私に確認を取る必要もありません。役に立つ部分は取って、要らない部分は捨てて、納得できないところは変えてください。
私は、自分しか触ったことのない1つのバージョンより、12通りの違うバージョンを見たいのです。
v1を独り占めせず、公開する理由
もう開発を止めたv1なんて誰も欲しがらないだろうと思い込み、10ヶ月も寝かせていました。その思い込みは間違っていました。そして、なぜ間違っていたかのほうが、リポジトリそのものより価値があります。
2つ目のバージョンは、方向性が分かれています。今作っているものはこのコードベースと似ておらず、このコードとの差分として語れるものでもありません。v1を非公開のままにしておけば、誰も訪れない博物館の展示物になっていっただけでしょう。
もっと良い理由は、動くマルチテナントのエージェントアプリケーションのほうが、アーキテクチャ図よりずっと役に立つ成果物だということです。私が学んでいたとき、助けになったのは概念の解説ではありませんでした——実際のプロジェクトを見つけて、誰かがそれをどう組んだのかを読むことでした。解説では失われてしまう思考の過程こそ、いちばん大事な部分です。
リポジトリのアーキテクチャノートには、私が間違っていて二度と同じことはしないと書いた項目が4つあります。4つとも、そのまま残しました。1つ目はセキュリティバグです。
世に出てしまった間違い
1つのテーブルが、Row Level Securityをオンにしないまま出て行きました。
public.notification_push_deliveriesは、RLSを有効にする一行を書かないまま作成され、匿名ロールと認証済みロールにTRUNCATEを含む全権限が付与されていて、ポリシーは一切定義されていませんでした。このテーブルはData APIが公開しているスキーマにあり、匿名キーはブラウザのバンドルに同梱されます。ですからその期間、キーを持っていれば誰でもこのテーブルを読んだり変更したりできました。プッシュ通知のデバイス識別子が入っていますから、これは理論上の話ではなく、データ露出の問題でした。
見つけたのはセキュリティレビューでした。バグレポートでも、テストでもありません。アプリケーションはその間ずっと完璧に動いていたからです。テーブルにRLSが欠けていても、エラーは何も出ません。クエリは成功し、機能は動き、存在してはいけない権限がただそこにある——誰かが見るまで、目に見える形では何もしないまま。
今は塞いであります。テーブルではRLSが有効でポリシーはなく、ブラウザロールの付与は取り消されています。かつて行を返していたはずのリクエストは、権限エラーとして返ってきます。ここをあえて過去形で書いているのは、意図的です。これはコードがかつてどうだったかであり、今日どうなっているかではないからです。
ここは正確に言っておきたいところです。これは私が正しく下した設計判断ではありません。私が間違えて、後から見つけたリグレッションです。
それで、自分の記憶を信じるのをやめました
この後やったことだけは、自信を持って人に渡せる部分です。
特定のテーブル名を一切書かないテストを書きました。スキーマを走査して、ルールのほうを検証します。公開スキーマのすべてのテーブルでRow Level Securityが有効になっていること、テナント単位のマテリアライズドビューがブラウザロールから読めないこと。来月テーブルを追加して忘れても、テストは落ちます。しかも、覚えていたときに書いたリストではなく、忘れたそのものを指して落ちます。
正直な留保が2つあります。どちらも、読者に後から見つけられるより、先に私へ突きつけてほしいものです。
- このチェックのうちマテリアライズドビュー側が対象にしているのは
publicスキーマで、agencyスキーマではありません。エージェンシー側には、まだ塞げていない露出が独自に残っています。 - データベースに接続できないとき、このテストは自分自身をスキップします。スキップされたテストはグリーンです。つまり、壊れた環境が、破れている不変条件を隠せてしまいます。
この一連の経験から、習慣として1つだけ残ったものがあるとすれば、これです。この形のバグを見つけたときは、それを捕まえられたはずのテストを書きます——しかも、オブジェクトではなくルールに対して書きます。覚えていた3つのオブジェクトを名指しするテストは、その3つを守るだけです。ルールを検証するテストは、来週追加して忘れてしまう1つを守ります。
2つ目の罠
2つ目は、設計ではなく運で正しくできていたもので、ベンダーとの会話で何度も聞くやつです。
クライアント側のフィルターは境界ではありません。アプリケーションがブラウザ上で組織ごとに行を絞り込んでいるなら——.eq('org_id', ...)の類です——その分離はユーザーのマシン上で動いていることになり、開発者ツールで外せてしまいます。外せるものは表示設定であって、セキュリティ制御ではありません。行の可視性は、データベースか、呼び出し側が迂回できないサーバーエンドポイントの裏側で強制しなければなりません。
マテリアライズドビューは、この問題の厄介な親戚です。マテリアライズドビューにはRow Level Securityをそもそも設定できません。マテリアライズドビューへのSELECT付与は全テナントの行を返しますが、マイグレーション上は、テーブルへの同じ付与(こちらはポリシーが効きます)と見分けがつきません。リポジトリが、テナント単位でマテリアライズド化されるものについてブラウザロールへの付与を取り消しているのは、まさにそのためです。
まだ片づいていないもの
Row Level SecurityはTRUNCATEを制限できません。これは行レベルの権限ではなくテーブルレベルの権限なので、ポリシーが適用されないのです。リポジトリのベースラインの付与は、多数のテーブルについてブラウザロールにALLを与えていて、これにはTRUNCATEが含まれます。そして、それを後から狭めるものは何もありません。マテリアライズドビューの付与は取り消してあります。こちらは取り消していません。つまり、このセクションでずっと説明してきた境界の外側に、ブラウザロールが権限を持っているテーブルが存在するということです。
実際には、これは開いた扉というより潜在的なものです。Data APIにはTRUNCATEの動詞がありませんし、ブラウザロールはデータベースに直接接続できませんし、通常のデプロイではデータベースのポートを公開しません。衛生上の問題です。ただ、形としてはこの投稿の冒頭のバグと同じで——正当な理由を静かに生き延びた権限——読者に見つけられて、私が知っていたのかと疑問に思われるより、自分から指しておきたいのです。必要なのは、両方のスキーマにわたる取り消しと、ブラウザロールがRLSを迂回する権限を持っていないことを検証するテストです。
二度と同じことはしないと決めたこと
2つの並行スキーマは、DDLを大量に重複させました。 エージェンシーと個別事業者のデータは、テナント判別カラムを持つ1つのテーブルではなく、並行する構造を持っています。分離そのものは確かにきれいです。ただ、重複はメンテナンスのコストとして、判別カラムを選んだ場合を上回りました。次は逆の判断をします。
手動のファンクションコーリングは、コード量がすごいことになります。 エージェントループは、プロバイダーの自動ファンクションコーリングを使わず、ツール呼び出しを手作業で処理しています。そのぶん、ストリーミングとツール実行をアプリケーション側で制御できます。代償は、関数の結果パーツをアプリケーション自身が組み立てなければならないことで、現在のモデルではそのパーツにファンクション名だけでなくcall idも必要です。idを省いてもスキーマエラーにはなりません。返ってくるのは、モデルが気まぐれを起こしているようにしか見えない何かです。スキーマエラーより、よほど嫌な午後になります。ツール併用のストリーミングが今ならより高レベルのAPIで使えるなら、このトレードオフは見直す価値があります。
マイグレーションの連鎖は321ファイルに達しました。 その多くがfix_、_v2、remove_という名前でした。すでに書いたものを編集せず、修正を後ろに足し続けていたからです。スキーマの本当の状態は、履歴を最初から再生しないと分からないものでした。当時書いた投稿は、その時期についてコードが示す以上に自信たっぷりでした——2025年11月には、33個のマイグレーションでマルチテナンシーが「ついに解決した」と書き、その後も何ヶ月も修正用のマイグレーションを書き続けていました。
フロントエンドは、APIの形を検証せずに信じています。 入り口側の検証は丁寧で、バックエンドはすべてのリクエストをPydanticでパースしています。出口側には何もありません。ブラウザはレスポンスを受け取って、それを信じます。共有スキーマがあれば、フィールドの形が変わったときのズレを捕まえられました。空白の画面になって初めて気づく、という事態を避けられたはずです。ここまでの3つと合わせて、アーキテクチャノートに「次は違うやり方をする」と書いてある4項目になります。
公開したリポジトリでは、マイグレーションの連鎖を20個の階層化されたマイグレーションとして、関心事ごとにまとめ直しています——テーブル、次にドメインごとにまとめた関数、ビュー、インデックス、トリガー、ポリシー、グラント、スケジュールジョブ、そして最後のハードニングです。正確にそうだ、というよりおおむねそうだ、という表現のほうが近いでしょう。どこで崩れているかは正直に書いておきます。20個のうち1つは、分類できなかった関数のための、いわば何でも放り込む受け皿だと認めていますし、テーブルの層はファイル名が示すようには分かれていません。共有スキーマの名前を冠したファイルが8つのエージェンシーテーブルも作っていて、エージェンシースキーマの名前を冠したファイルにはテーブル定義が一切入っていません。階層化は本物ですが、ラベルは完璧ではありません。
この分割が正しくできているのは、読むうえで重要な部分です。スキーマは純粋な再編成で、前後でダンプし、ダンプツールのランダムトークンを除いて2つがバイト単位で同一であることを確認しています。
その検証があったからこそ、私はこれを触る気になれたのです。
生き残ったもの
切り替える機能としてではなく、実際の境界としてのRow Level Security。 設計がそれを中心に組まれると、分離はチェックリストの項目ではなくなり、システムの性質になります。
書き込みはルーティングされたデータベース関数を通ります。 アプリケーションコードが、どのスキーマに触るかを選びません。組織の種類を調べて振り分ける関数を呼ぶだけです。判断は1か所に集まり、新しいコードパスが判断を忘れることはできません。
サインアップはクライアントの入力を無視します。 プロビジョニングトリガーは、クライアントから渡された組織IDやロールを採用しません。そのデータはユーザーが制御できるものだからです。新しい組織と、デフォルトのオーナーロールを作ります。小さなことですが、間違えやすく、間違えると高くつきます。
遅延させたクライアント生成。 各サービスは、インポート時ではなく初回利用時にプロバイダークライアントを生成します。スタイルの好みに聞こえますが、そうではありません。いくつかのサービスはモジュールのインポート時に生成されるため、即時生成だとアプリケーションをインポートするだけでAPIキーが必要になり、何かが起動する前に、プロバイダーSDKからの分かりにくいエラーとして表面化していました。遅延させたからデモモードが可能になり、それによって、見ず知らずの人でも無料でこのプロジェクトを試せます。
最後のこれは、私がいちばん満足している判断です。ただ、それが重要になる理由でそうしたわけではありません。クラッシュを止めるためにやったことです。
コードを読むつもりのない方へ
エージェンシー側でこの種のソフトウェアを買っていた頃の私が、欲しかったであろうセクションです。コードは一切出てきません。
持ち帰る価値があるのは、**「アカウントで絞り込んでいます」と「データベースが他アカウントの行を返せません」**の違いです。
キャリアの大半でプラットフォームを買い、ここ数年は作る側に回りましたが、その違いは、間違ったほうを世に出してしまうまで理解できていませんでした。片方はブラウザが従うルールです。もう片方はブラウザが破れないルールです。ほとんどのツールは前者を持っていて、それを後者の言葉で説明します。
複数のクライアントのデータ——競合インテリジェンス、パフォーマンスデータ、オーディエンス定義、何であれ——を預けるプラットフォームを評価するとき、聞くべきは「安全ですか」ではありません。誰もがそれには「はい」と答えます。聞くべきはこれです。その分離はどこで強制されていて、開発者がフィルターを外したら何が起きるのか?
良い答えは2つです。サインインしているユーザーのIDに紐づいたポリシーによってデータベースで強制されているか、ブラウザが迂回できないサーバーエンドポイントの裏で強制されているか。ブラウザが関わる答えは、すべて不合格です。
良い答えはこうです——ポリシーはテーブルにあって、ユーザーのセッションに紐づいている。ブラウザはそのテーブルを直接クエリしない。それを証明するテストがある。役に立たない答えはこうです——うちのアプリはアカウントで絞り込んでいる。データは暗号化されている。SOC 2に準拠している。どれも事実かもしれませんが、どれも質問に答えていません。
一行で済みますし、ベンダーセキュリティレビューにちょうど収まるので、私ならそこに置きます。答えを聞けば、マルチテナンシーが最初から設計に組み込まれていたのか、後付けなのかが分かります。
よくある質問
なぜ非公開リポジトリに眠らせず、公開するのか?
公開された成果物は検証できますが、非公開のものは検証できません。以前この開発について書いた投稿は主張を並べていましたが、マイグレーションとポリシーとテストが入ったリポジトリなら、読者が自分の目で確かめられます。私が間違った部分も含めて。アーキテクチャノートには「トレードオフ、そして次はどうするか」というセクションがあり、このリポジトリがこの形で存在する理由がそこにあります。
2つ目のバージョンはオープンソースになりますか?
いいえ。非公開で開発しており、このコードベースと似たものになる見込みはありません。曖昧にしておいて、ロードマップを期待してクローンさせるより、はっきり言っておきたいのです。
本番環境で使ってもいいですか?
私なら使いません。これはリファレンスであって、プロダクトではありません。シードデータのデモ用認証情報はローカル利用専用ですし、サポートの約束もなく、将来の依存関係の変化に合わせて直してくれる人もいません。読んで一部を借りてくるには良いもので、その上でビジネスを動かすには悪いものです。
試すのにAIのキーは必要ですか?
いいえ。デモモードで起動し、エージェントはモデルを呼ばずに、明確にラベル付けされた定型出力を返します。キーなしで、一銭も使わずに、アプリケーション全体をクリックして回れます——すべてのエージェント、エージェンシーのクライアントフロー、言語の切り替えまで。実際のモデル呼び出しに切り替えるのは、設定1つとキー1つです。私の初回は10分以上かかりました。Dockerが動いていること、Node、Python、Poetry、Supabase CLIが必要で、時間がかかるのは大きなダウンロードです。
これを作っている間、作ることについてたくさん書いてきました。最初に私が挙げるなら、なぜ2日目にマルチテナンシーを構築したのかと、67日目に作り直したときに何が起きたのかの2本です。どちらか1本だけ時間を割く価値があるとすれば、それは2本目です——アーキテクチャが間違っていたと分かったのは、そちらなので。
コードはgithub.com/chandlernguyen/stratum-ossにあり、私が出してしまったミスを防いでいるテストはtests/automated/test_rls_coverage.pyにあります。
マルチテナントシステムを世に出して、私が触れなかった3つ目の静かな罠を見つけた方がいたら、ぜひ聞かせてください——そういうのこそ、集める価値があります。
フィードバックと提案、そしてv1のこれから
これを一方通行の配信にはしたくないので、何を期待してよいかの正直なバージョンを書いておきます。
歓迎するもの: リポジトリのどこかが単純に間違っている場合のバグ報告。もっと明確に、もっとシンプルに、もっと少ないコードで書ける部分への提案。実際に動かしてみて、READMEに書かれていない何かにぶつかった人のメモ。本当の問題を見つけて直したい人のプルリクエスト。そして、フォークして自分の何かを作ったなら、何をなぜ変えたのかを知りたいです——それがいちばん面白いフィードバックです。実際に自分で判断したのですから。
約束できること: ほとんどありません。そう匂わせるより、はっきり言っておきます。これは活発に開発しているプロジェクトではありませんし、サポート窓口をやっているわけでもなく、返信までの時間を約束することもできません。実行に移す提案もあれば、読んで同意しながら、結局手が回らないままの提案もあります。後継がすでにいる個人プロジェクトの、現実的な姿がこれです。
v1のこれから: そのまま公開され続けます。これ以上開発しないので、新しいリリースを前提にしないでください。ただし、封をしたふりはしません——コードは公開されていますし、ライセンスは好きな方向に持っていくことを許しています。壊れているものや本当に分かりにくいものがあれば、原則論でそのままにしておく理由は私にはありません。
いちばん簡単な連絡方法は、リポジトリのissueです。表に出たくない場合はメールでも構いません。
今回はここまでです。
それでは、Chandler