| 項目 | 内容 |
|---|---|
| ドキュメント版数 | v0.5 |
| 更新日 | 2026-08-12 |
| 主な変更 | 実装との差分を解消。 §2.1a に簡易認証の 3 本、問答系に GET /cost、/v1/health の応答(limits)、原因宣言の verdict: null、スコア 5 軸の null(出題対象外)を明記 |
| ホスト | enebular クラウド実行環境の HTTP トリガー URL |
⚠️ v0.1 の実装対象は §2.1(問答)と §2.2(個人)のみ。 §2.3(演習・問題集)と §2.4(組織)は v0.2 以降(F16 Won't)。 認証は簡易版(メール + パスワード + 招待コード)で、 ロール・テナント・監査ログは v0.2。詳細は scope-v0.1.md。v0.1 では同一オリジン配信のため CORS は不要(ADR-012)。 フロントは相対パス
/v1/...で API を呼ぶ。
enebular の HTTP トリガーはパスを 1 本しか持てない(インスタンス内で一意)。 そのため、トリガーのパスをアプリのルートとし、その配下のルーティングは 関数内部の Hono が行う(ADR-009)。
https://<enebular-http-trigger>/socrametry/v1/sessions
└─ トリガーのパス ─┘└─ Hono の内部ルート ─┘
以下、パスは Hono の内部ルート(/v1/...)で表記する。
BtoB 提供のため、すべての API は認証必須(/v1/health を除く)。
OIDC / OAuth の認可コードフローでログインし、セッション Cookie を発行する。
Set-Cookie: sm_session=<jwt>; HttpOnly; Secure; SameSite=None; Max-Age=86400; Path=/
v0.1 では
SameSite=Lax。 フロントを関数から同一オリジンで配信するため、Noneにする必要がなく、CORS 設定も不要(ADR-012)。v0.2 でフロントを別ホスティングに分離する場合は
SameSite=None; Secureが必須になり、 CORS でAccess-Control-Allow-Credentials: trueとAccess-Control-Allow-Origin: <ALLOWED_ORIGIN>(ワイルドカード不可)を返す必要がある。
トークンから取り出す認証コンテキスト
| # | 鉄則 |
|---|---|
| 1 | tenantId はトークンからのみ取得する。 パス・クエリ・ボディから受け取らない |
| 2 | データストアのメインキーは ownerId = "<tenantId>:<memberId>"。別組織はキーが一致せずそもそも引けない(data-model.md §6) |
| 3 | role はトークンではなく org_directory から都度引く(ロール変更を即時反映するため) |
| ロール | 自分 | 自チーム | 組織全体 | 問題集の編集 | 評価レポート出力 |
|---|---|---|---|---|---|
member |
✅ | 集計値のみ | 集計値のみ | ✗ | ✗ |
lead |
✅ | ✅ 個票 | 集計値のみ | ✅ | ✗ |
admin |
✅ | ✅ | ✅ 個票 | ✅ | ✅ |
権限外のアクセスは 403 FORBIDDEN を返し、監査ログに記録する(NFR-S9)。
すべて application/json; charset=utf-8。Lambda ハンドラは
{ statusCode, headers, body } を返すため、ストリーミングは行われない。
エラーは以下の形式。
{
"error": {
"code": "SESSION_NOT_FOUND",
"message": "セッションが見つかりません",
"detail": null
}
}| HTTP | code | 意味 |
|---|---|---|
| 400 | INVALID_INPUT |
Zod バリデーション失敗 |
| 401 | UNAUTHENTICATED |
未ログイン / トークン期限切れ |
| 403 | FORBIDDEN |
ロールの権限外 / 他テナントへのアクセス |
| 404 | SESSION_NOT_FOUND |
存在しない / 他人のセッション |
| 409 | SESSION_COMPLETED |
完了済みセッションへの操作 |
| 409 | HINT_EXHAUSTED |
ヒントレベルが既に 3 |
| 409 | GATE_NOT_UNLOCKED |
遷移条件を満たしていないゲートへの要求 |
| 429 | RATE_LIMITED |
レート制限(NFR-O3) |
| 429 | TOKEN_BUDGET_EXCEEDED |
セッションのトークン上限超過(NFR-C1) |
| 503 | LLM_UNAVAILABLE |
OrcaRouter への接続失敗(フォールバックも失敗) |
| 503 | DATASTORE_UNAVAILABLE |
enebular データストアへのアクセス失敗 |
| 500 | INTERNAL_ERROR |
想定外の例外。detail は返さない(内部情報の露出を避けるため) |
202 Accepted はエラーではなく待機を意味する(→ §3.5)。
detailに利用者の入力を含めない。 貼り付けたエラーテキストには 秘匿情報が含まれる前提であり(security.md §3)、エラー応答に混ぜると マスキングを迂回することになる。DATASTORE_UNAVAILABLEのdetailは 失敗した操作名(例:sessions.putItem)までとし、 データストアが返した生のメッセージはログにも応答にも出さない。
| フィールド | 保存先 | 理由 |
|---|---|---|
rootCause / evidence / confidence |
session_secrets (kind: "diagnosis") |
答えそのもの |
correctOptionId |
session_secrets (kind: "answerkeys") |
正解が分かってしまう |
rationaleIfWrong の全量 |
同上 | 選ばれた選択肢の分だけ回答後に返す |
packages/shared の公開型にこれらを定義しないことで、型レベルで防ぐ。
さらにデータストア上も別テーブルに隔離されているため、
セッションアイテムをそのまま返しても漏れない(ADR-005)。
開示は POST /reveal と GET /report のレスポンスでのみ行う。
公開型は QuestionPublic(正解なし)と RevealPublic(答えあり)を別の型として定義し、
どのエンドポイントがどちらを返すかを型で固定する。
| メソッド | パス | 説明 | ロール | レイテンシ |
|---|---|---|---|---|
| POST | /v1/sessions |
セッション開始。Gate A のヒントを返す。診断は待たない | member | 〜5 秒 |
| POST | /v1/sessions/:id/diagnose |
先行診断の実行(クライアントが即座に発火) | member | 〜20 秒 |
| GET | /v1/sessions/:id |
セッション状態の取得(復帰用) | member | 〜1 秒 |
| GET | /v1/sessions/:id/transcript |
中断からの復旧用の記録(#27 / 下記) | member | 〜1 秒 |
| POST | /v1/sessions/:id/hints |
Gate A: ヒントレベルを 1 段階上げる | member | 〜3 秒 |
| POST | /v1/sessions/:id/advance |
Gate A → B: 設問に進む | member | 〜5 秒 |
| POST | /v1/sessions/:id/answers |
Gate B: 選択肢に回答し、次の設問を得る | member | 〜4 秒 |
| POST | /v1/sessions/:id/conclusion |
原因を宣言し、到達判定を受ける | member | 〜5 秒 |
| POST | /v1/sessions/:id/reveal |
Gate C: 解説を読む(遷移条件を満たす場合のみ) | member | 〜1 秒 |
| POST | /v1/sessions/:id/retrospect |
Gate C 後の振り返り 1 問に回答する | member | 〜1 秒 |
| GET | /v1/sessions/:id/report |
振り返りレポートとスコア | member | 〜10 秒(初回) |
| GET | /v1/sessions/:id/cost |
1 セッションの実測コスト(F11 / 下記) | member | 〜1 秒 |
| DELETE | /v1/sessions/:id |
セッション削除(NFR-S7) | member | 〜2 秒 |
GET /costは本書の初版に無かったエンドポイント。 実測コスト表 (cost-model.md §5.4)を埋めるにはops_logsを読む手段が要り、 無いと実行環境のログを人が読むしかない。デモ台本 #6「コストログを見せる」も 画面から出せない。ops_logsのメインキーはsessionIdでownerIdを含まないため、 先にsessionsを読んで所有者を確認してからでなければ呼んではならない。
本書の §1 は OIDC / OAuth を前提に書かれているが、v0.1 は簡易認証
(メール + パスワード + 招待コード / scope-v0.1.md §4.2)。
そのため以下の 3 本が実装されている。認証不要で到達できるのはここと /v1/health だけ。
| メソッド | パス | 説明 |
|---|---|---|
| POST | /v1/auth/signup |
招待コード必須。成功で Cookie を発行し 201 |
| POST | /v1/auth/login |
Cookie を発行 |
| POST | /v1/auth/logout |
Cookie を破棄(JWT はステートレスなのでサーバ側に破棄する状態はない) |
// POST /v1/auth/signup
{ "email": "sato@example.com", "password": "8 文字以上", "displayName": "佐藤", "inviteCode": "..." }
// → { "me": { "userId": "usr_9d0e11", "email": "...", "displayName": "佐藤" } }| HTTP | code | 意味 |
|---|---|---|
| 403 | INVALID_INVITE_CODE |
招待コードが違う |
| 409 | EMAIL_TAKEN |
登録済みのメールアドレス |
| 401 | INVALID_CREDENTIALS |
メールまたはパスワードが違う(どちらかは返さない) |
Cookie の
Pathはトリガーのパス配下に絞る。 enebular は 1 ホストを複数 インスタンスがパスで分け合うため、Path=/だと同じホストの他の関数にも JWT が送信される(HttpOnlyは JS からの読み取りを防ぐだけで、送信は止められない)。 トリガーのパスは設定として持たず(ADR-009)、 リクエストのパスから導く。
| メソッド | パス | 説明 | ロール |
|---|---|---|---|
| GET | /v1/me |
自分のプロフィール(v0.1 はロールを持たない) | member |
| GET | /v1/me/sessions |
自分の履歴一覧 | member |
| GET | /v1/me/stats |
自分のダッシュボード(到達ゲート分布・5 軸推移)※成長率は v0.2 | member |
| GET | /v1/me/assignments |
自分に割り当てられた演習一覧 ※v0.2 | member |
| メソッド | パス | 説明 | ロール |
|---|---|---|---|
| GET | /v1/problems |
社内問題集の一覧(タグ・難易度で絞り込み) | lead |
| POST | /v1/problems |
問題を登録する | lead |
| PATCH | /v1/problems/:id |
問題を編集する(難易度・タグ・公開状態) | lead |
| POST | /v1/problems/from-session/:sessionId |
実務セッションを匿名化して問題集に登録(FR-35) | lead |
| POST | /v1/assignments |
問題セットをメンバー / チームに割り当てる | lead |
| GET | /v1/assignments |
割り当ての進捗一覧 | lead |
| メソッド | パス | 説明 | ロール |
|---|---|---|---|
| GET | /v1/org/dashboard |
組織ダッシュボード(事前集計を 1 クエリで読む) | member(範囲はロール依存) |
| GET | /v1/org/members |
メンバー一覧とロール | lead |
| POST | /v1/org/members |
メンバーを招待する | admin |
| PATCH | /v1/org/members/:id |
ロール / チーム / 有効状態を変更する | admin |
| GET | /v1/org/ranking |
ランキング(組織設定で有効な場合のみ) | member |
| GET | /v1/org/settings |
組織設定 | admin |
| PATCH | /v1/org/settings |
ランキング可否・保持期間などを変更する | admin |
| POST | /v1/org/reports/evaluation |
評価レポートを出力(FR-27) | admin |
| GET | /v1/health |
ヘルスチェック(認証不要 / 下記) | — |
GET /v1/health の応答
{
"status": "ok",
"version": "0.1.0",
"commit": "d66a9ca...", // デプロイしたコミットが動いているかの確認用
"builtAt": "2026-08-11T01:02:11.458Z",
"mockMode": false, // 本番で true のまま公開していないかの目視確認(deployment.md §6 #10)
"configOk": true, // 環境変数の設定漏れ。★キー名は返さない(認証不要のため)
"configMissing": 0,
"limits": { // 実際に効いている設定値。環境変数と既定値のどちらが効いているか外から分かる
"stages": 5, // Gate B の段階数(DEMO_MAX_STAGES)
"diagnoser": 1600, // 役割別の max_tokens
"hinter": 300, "questioner": 900, "judge": 500, "revealer": 1000, "reporter": 1000,
"usdJpyRate": 165, // 円換算レート(USD_JPY_RATE)
"opsLog": true // ops_logs への記録が有効か(OPS_LOG_ENABLED)
}
}
limitsを返すのは、設定の食い違いを LLM を呼ばずに確認するため。 環境変数を消したのに古い値が効いたままの状態を切り分けられず、 実際に LLM 呼び出しを 2 回無駄にした。値は上限であって秘匿情報ではない。
usdJpyRateとopsLogはコスト計測のために後から追加した。 前者は実行環境 165 / コード既定 150 でずれ、設計書の円が 150 換算と 165 換算で混在した。後者は無効だと課金されるのに記録が残らず、GET /costが MOCK と同じ「呼び出し 0 件」を返して見分けがつかない。 いずれも「設定が効いているか外から見えない」ことで実害が出た項目である。
すべてのレイテンシは実行環境のタイムアウト設定内に収める必要がある。 タイムアウトは
enebular bulk-update cloud-configのtimeoutで設定する。 実測前の暫定値は 30 秒とし、Day 1 午前に上限を確認する(未決 Q-5)。
リクエスト(実務モード)
{
"mode": "live",
"errorText": "TypeError: Cannot read properties of undefined (reading 'map')\n at ProductList (ProductList.tsx:24:18)",
"codeSnippet": "const ProductList = ({ items }) => items.map(...)", // 任意
"language": "typescript", // 任意(未指定なら推定)
"framework": "nextjs", // 任意
"recentChange": "APIのレスポンス形式を変えた" // 任意
}リクエスト(演習モード)
{ "mode": "assessment", "problemId": "01J8A7R2K50000000000000001", "assignmentId": "01J8XM..." }演習モードでは、エラー本文も内部診断も question_bank から取得する。
Diagnoser を呼ばないため速く、かつ全員が同一の診断で出題される(NFR-F2)。
| フィールド | 型 | 必須 | 制約 |
|---|---|---|---|
mode |
string | ✅ | live | assessment |
errorText |
string | live で ✅ | 1〜20,000 文字 |
codeSnippet |
string | 〜10,000 文字 | |
language / framework |
string | 事前定義リスト | |
recentChange |
string | 〜1,000 文字 | |
problemId |
string | assessment で ✅ | 自組織の公開済み問題のみ |
レスポンス 201 Created
{
"session": {
"id": "01J8XK4M2N0000000000000001",
"mode": "live",
"status": "active",
"gate": "A",
"hintLevel": 1,
"diagnosisStatus": "pending", // ← クライアントはこれを見て diagnose を撃つ
"startedAt": 1786000000000,
"autoAdvanceInMs": null // ← 時間経過による Gate A → B までの残り時間
},
"hint": {
"level": 1,
"body": "エラーメッセージの後半に注目してみてください。"
},
"actions": {
"canRequestHint": true, // Lv2 へ
"canAdvanceToQuestions": true, // Gate B へ(利用者の意思で即進める)
"canDeclareConclusion": true, // 「解決した」— Gate A での自力解決
"canReveal": false // Gate C はまだ塞がっている
}
}設問は返さない。 Gate A は着眼点のヒントのみ。 ここで解決できたセッションが最上位評価(
gate_factor1.00)になる。
中断していた時間は時間条件から差し引く(#27)
sessions は lastSeenAt(最後の操作)と awayMs(中断の合計)を持つ。
セッションを読むすべての経路で空白を判定し、次のように扱う。
| 空白 | 扱い |
|---|---|
| 1 分未満 | ふつうの操作間隔。何もしない |
| 1 分以上 | awayMs に足す。autoAdvanceInMs と Gate C の解放から引かれる |
SESSION_ABANDON_AFTER_MS(既定 24 時間)以上 |
status: "abandoned"。以降の操作は 409 |
2 つのタイマーは「詰まった人を助けるための安全弁」であって評価のための計測ではない (socratic-engine.md §7 / P2)。中断を数えると **「昨日のセッションを開いた瞬間に設問へ飛ぶ」「一晩置けば解説に直行できる」**が起きる。
startedAtは書き換えない。履歴の表示に使う値なので、事実として動かさない。
autoAdvanceInMs について(FR-07 の時間経過による Gate A → B)
session を返すすべての応答に載る。時間経過で設問へ移るまでの残りミリ秒で、
条件を満たしていなければ null。
| なぜこの形か | |
|---|---|
| クライアントにタイマーを置くため | 実行環境が Lambda であり、定期実行を持てない。タイマーの置き場所がクライアントしかない |
| 条件ではなく「いつ」だけを渡す | 発火条件(ヒント Lv3 開放 かつ 一定時間経過)はサーバの core にある。条件式を渡すと遷移規則が 2 箇所に分かれて必ずずれる |
| 絶対時刻ではなく残り時間 | クライアントの時計はサーバとずれる。残り時間なら時計のずれが乗らない |
クライアント側の責務は 2 つだけ。
autoAdvanceInMs経過後にPOST /advanceを撃つ- 利用者が入力中は適用しない(socratic-engine.md §7 の判断 3)。 これもサーバからは観測できないため、クライアントにしか置けない
処理フロー
- 入力をマスキング(FR-11)
- ULID で
sessionIdを採番 - Gate A の Lv1 ヒントを生成。
- 実務モード: エラーテキストのみから生成できる汎用ヒント(診断不要)
- 演習モード:
question_bank.diagnosis.focusHintsから生成(LLM 呼び出しゼロ)
sessionsに put(gate: "A",diagnosisStatus: "pending")- 返却。診断は行わない
診断(10〜20 秒)を待たないことが、NFR-P1(5 秒以内)を満たす鍵。 Lambda はレスポンス返却後に処理を続けられないため、診断は次の
POST /diagnoseに分離する (ADR-006)。 演習モードでは診断が事前に存在するため、diagnosisStatusは最初から"ready"になる。
クライアントは最初の質問を描画した直後にこれを撃つ。 レスポンスを待つ必要はない
(fetch(...).catch(() => {}) で投げっぱなしにしてよい)。
リクエスト: ボディ不要
レスポンス 200 OK
{ "diagnosisStatus": "ready" }diagnosisStatus が既に pending 以外なら、何もせず即座に 200 を返す(冪等)。
診断に失敗した場合は "failed" を返し、以降の質問は
focusHints なしの汎用モードで生成する(体験は劣化するが継続はできる)。
ユーザーが Lv1 の選択肢を読んで考えている 20〜60 秒の間に、この処理が完了する。
リクエスト: ボディ不要
レスポンス 200 OK
{
"hint": { "level": 2, "body": "`reading` の直後の単語が、失敗した操作を表しています。" },
"session": { "gate": "A", "hintLevel": 2 },
"actions": { "canRequestHint": true, "canAdvanceToQuestions": true, "canDeclareConclusion": true, "canReveal": false }
}hintLevel が既に 3 の場合は 409 HINT_EXHAUSTED。
利用者が「設問に進む」を押したとき、または一定時間経過による自動遷移で呼ぶ。
レスポンス 200 OK
{
"session": { "gate": "B", "currentStage": "observe", "stageIndex": 1, "totalStages": 5 },
"question": {
"id": "01J8XK4M2N0000000000000001#1",
"stage": "observe",
"seqInStage": 1,
"body": "このエラーメッセージは、何が undefined だったと言っていますか?",
"options": [
{ "id": "a", "label": "map という名前の変数" },
{ "id": "b", "label": "map を呼び出そうとした対象のオブジェクト" },
{ "id": "c", "label": "map に渡したコールバック関数" },
{ "id": "d", "label": "map の戻り値" }
]
},
"actions": {
"canRequestHint": true,
"canAdvanceToQuestions": false, // ← Gate B に入ったので false
"canDeclareConclusion": true,
"canReveal": false
}
}
sessionを返す応答には必ずactionsを付ける。 画面は「どのボタンを出してよいか」をactionsだけで決めている(§7 の設計判断)。 付け忘れると、クライアントは1 つ前の状態を出し続ける。 実際にこのエンドポイントがactionsを返しておらず、Gate B に入った後も Gate A のボタン(「設問に進む」)が画面に出たままになっていた。 検査はapi.test.tsの「session を返す応答には必ず actions が付く」。
注意: この遷移は不可逆。以降 Gate A の評価(gate_factor 1.00)は得られない。
クライアントは実行前に確認ダイアログを出す。
診断が未完(diagnosisStatus !== "ready")でも、Lv1 の設問は診断なしで生成できるため
このエンドポイントは成功する。診断が必要になるのは Lv2 以降。
リクエスト
{
"questionId": "01J8XK4M2N0000000000000001#1",
"selectedOptionId": "b",
"elapsedMs": 12400
}レスポンス(通常) 200 OK
{
"result": {
"isCorrect": true,
"feedback": "その通りです。呼び出し対象を特定できましたね。"
},
"session": {
"currentStage": "localize",
"stageIndex": 2,
"hintLevel": 0,
"status": "active"
},
"nextQuestion": {
"id": "01J8XK4M2N0000000000000001#2",
"stage": "localize",
"seqInStage": 1,
"body": "そのオブジェクトは、どこから渡ってきていますか?",
"options": [ /* ... */ ]
},
"canDeclareConclusion": true, // 「原因が分かった」ボタンの表示可否
"canReveal": false // 「答えを見る」の解放状態(FR-14)
}レスポンス(診断が未完) 202 Accepted
Lv2 以降の質問は focusHints を必要とするため、診断が終わっていない場合は待たせる。
{
"result": { "isCorrect": true, "feedback": "その通りです。" },
"nextQuestion": null,
"pending": { "reason": "DIAGNOSIS_IN_PROGRESS", "retryAfterMs": 3000 }
}クライアントは retryAfterMs 後に同じリクエストを再送する。
回答は既に記録済みのため、再送しても二重にはならない(§4 冪等性)。
挙動
- 不正解の場合、
nextQuestionは同一段階の別角度の質問になる - 同段階 3 問目も不正解なら
hintLevelが自動で 1 上がり、nextQuestionに反映される - 最終段階(
fix)を通過した場合、nextQuestionはnull、session.statusはcompleted
Gate A・B のどちらからでも呼べる。 Gate A で呼んで reached なら自力解決(★★★)。
リクエスト
{ "body": "APIのレスポンスが返る前の初回レンダリングで items が undefined になっていた" }レスポンス 200 OK
{
"verdict": "reached", // reached | partial | not_reached | null
"feedback": "その通りです。データが到着する前の状態を見落としていた、という構造ですね。",
"session": { "status": "completed", "reachedGate": "A" },
"reportPath": "/v1/sessions/01J8XK4M2N0000000000000001/report"
}partial / not_reached の場合は session.status は active のままで、
Gate A なら次のヒント、Gate B なら該当段階の設問に戻る。このとき原因は明かさない。
「わかりません」や短すぎる入力は、3 値判定そのものを回避する。
{
"verdict": null,
"skipped": true, // 判定を行わなかった場合のみ true
"feedback": "ここまでで分かったことだけでも大丈夫です。設問に戻って絞り込むか、解説を読むかを選べます。"
}not_reached にしてはいけない。 「分からない」と表明した利用者を設問へ戻すのは、
socratic-engine.md §4.3 の「詰まった人を助ける」目的と逆になる。
判定に LLM を使わないため課金も発生しない。
判定するかどうかは決定的に決める(packages/core/src/conclusion-input.ts)。
対象は「わかりません」等の定型表現と、正規化後 10 文字未満の入力。
遷移条件(socratic-engine.md §7)を満たさない場合は
409 GATE_NOT_UNLOCKED。
レスポンス 200 OK
{
"session": { "gate": "C", "status": "active", "reachedGate": "C" },
"reveal": {
"rootCause": "props の items が API 応答前の初回レンダリングで undefined だったためです。",
"evidence": [
"スタックトレース 3 行目が ProductList.tsx:24 を指しています",
"items は親から props で渡され、フェッチ完了前は未定義のままです"
],
"fixDirection": "データ到着前の状態を明示的に扱います。初期値を与えるか、未到着時の表示を分けます。",
"prevention": "非同期データを受け取る props には、未到着の状態を型で表現しておくと同種のバグを防げます。"
},
"retrospection": {
"question": "今回、どの段階で見落としがありましたか?",
"options": [
{ "id": "a", "label": "エラーメッセージの読み取り" },
{ "id": "b", "label": "変更点の洗い出し" },
{ "id": "c", "label": "原因の推論" },
{ "id": "d", "label": "仮説の確かめ方" }
]
}
}UI 文言は「答えを見る」ではなく「解説を読む」。 開示は敗北ではなく正しい着地点の一つとして扱う (evaluation-model.md §4.1)。
retrospection への回答は POST /v1/sessions/:id/retrospect で送る。
スコアには使わない(自己申告のため)が、振り返りレポートの材料になる。
回答するとセッションが completed になる。
sessions に入っている記録を時系列に並べ直して返す。 画面はこれを順に流して
スレッドを組み直す。
{
"session": { /* SessionPublic */ },
"entries": [
{ "kind": "error", "at": 1786000000000, "body": "TypeError: …", "language": "typescript" },
{ "kind": "hint", "at": 1786000000100, "level": 1, "body": "…", "auto": true },
{ "kind": "question","at": 1786000060000, "stage": "observe", "seqInStage": 1,
"body": "…", "options": [/* … */],
// 未回答なら null。再開後に続きから答える対象
"answer": { "selectedOptionId": "b", "isCorrect": true, "feedback": "…" } },
{ "kind": "conclusion", "at": 1786000200000, "body": "…", "verdict": null, "feedback": "…" }
// Gate C 到達済みなら reveal / retrospection が続く
],
"question": null, // 未回答の設問
"actions": { /* … */ }
}答えが漏れる新しい経路は作っていない。 元になる
sessionsには正解 ID も内部診断も入っていない(ADR-005 でsession_secretsに隔離済み)。並べ直しただけの応答は構造として答えを含みようがない。唯一の例外が
revealで、これは答えそのもの。gateがCの場合にだけ載せる。 検査はapi.test.tsの「Gate C の前は解説を含まない(DoD #2)」。
conclusions は冪等判定用のハッシュに加えて本文(マスキング済み)も保存する。
ハッシュだけだと、再開したときに自分が何を書いたか復元できないため。
セッションが completed のときのみ 200。それ以外は 409。
初回アクセス時に Reporter が生成して reports テーブルに put し、
同時に member_stats を更新する(data-model.md §3.5)。
2 回目以降はデータストアから読むだけ(LLM を呼ばない)。
DEMO_MAX_STAGESを 3 に絞った運用では、出題しなかった 2 段階の軸がnullになる (scoreExplanation.breakdown[].resultもnull)。0 点として集計してはならない。 実測で、全問正解なのに総合 49 点という 説明できない数値が出た。出題対象外の軸は重みを 0 にし、 出題した軸だけで重みを正規化して総合点を出す(→ 75 点)。
Gate A で自力解決した場合も、設問を 1 問も解いていないため 5 軸がすべて同じ値になる(evaluation-model.md §4.6)。 画面はこれを「軸ごとの強み弱み」として見せてはならない。
{
"mode": "live",
"reachedGate": "B",
"path": [
{ "stage": "observe", "attempts": 1, "hintLevel": 0, "elapsedMs": 12400 },
{ "stage": "localize", "attempts": 2, "hintLevel": 1, "elapsedMs": 48000 },
{ "stage": "hypothesize", "attempts": 1, "hintLevel": 1, "elapsedMs": 30100 },
{ "stage": "verify", "attempts": 1, "hintLevel": 1, "elapsedMs": 22000 },
{ "stage": "fix", "attempts": 1, "hintLevel": 1, "elapsedMs": 18000 }
],
"stumblingPoint": "切り分けの段階で、変更点の洗い出しに時間がかかりました。",
"generalizedLesson": "『X of undefined』は常に『X を持っているはずの入れ物が空だった』ことを意味します。まず入れ物の出所を辿るのが最短経路です。",
"nextTimeSteps": [
"エラーメッセージから、失敗した操作とその対象を特定する",
"スタックトレース最上位のアプリケーションコード行を開く",
"その値が非同期で来るなら、到着前の状態を必ず確認する"
],
"revealedAnswer": "props の items が API 応答前の初回レンダリングで undefined だったため。",
"score": {
// ★ 各軸は number | null。null は「出題対象外」(上記)
"observe": 100, "localize": 60, "hypothesize": 85, "verify": 85, "fix": 85,
"total": 81,
"gateFactor": 0.90,
"difficultyFactor": 1.0,
"timeIndex": null,
"previousTotal": 74,
"comparable": false
},
"scoreExplanation": {
"formula": "Σ(stage_score × weight) × gate_factor",
"docUrl": "/docs/evaluation-model#3-スコアの算出",
"breakdown": [
{ "axis": "localize", "base": 70, "hintPenalty": 0.85, "difficultyFactor": 1.0, "result": 60 }
]
}
}revealedAnswer は完了後にのみ返る唯一の答えフィールド。
reports テーブルにのみ存在し、進行中のセッション取得では読み出されない。
scoreExplanationは必須フィールド。 スコアが評価に使われる以上、 被評価者が算出根拠を確認できなければならない(NFR-F1)。 「なぜこの点数なのか」を説明できない数値を人事評価に使わせない、という要件の実装。
クエリ: ?limit=20&startKey=<sessionId>&mode=live
query(sessions, "#ownerId = :ownerId", order: false, limit) の 1 回で取得する。
ページングはデータストアの startKey をそのまま透過させる。
{
"sessions": [
{
"id": "01J8XK4M2N0000000000000001",
"summary": "TypeError: Cannot read properties of undefined",
"language": "typescript",
"mode": "live",
"status": "completed",
"reachedGate": "B",
"totalScore": 81,
"startedAt": 1786000000000
}
],
"nextStartKey": null
}getItem(member_stats, { tenantId, memberId }) の 1 アクセスで返す。
{
"sessionCount": 38,
"totalElapsedMs": 33120000,
"gateDistribution": { "A": 0.24, "B": 0.50, "C": 0.26 },
"selfReachRate": 0.74, // Gate A + B の割合
"recentAxes": { "observe": 88, "localize": 71, "hypothesize": 76, "verify": 58, "fix": 72 },
"previousAxes": { "observe": 80, "localize": 50, "hypothesize": 68, "verify": 56, "fix": 64 },
"growthRate": 12,
"orgAverage": { "observe": 82, "localize": 65, "hypothesize": 70, "verify": 61, "fix": 69 },
"correctRate": 0.72,
"timeIndexAvg": 1.08,
"weakestAxis": "verify",
"trend": [ { "sessionId": "01J8W...", "total": 62, "gate": "B", "at": 1785000000000 } ]
}orgAverage は組織平均のみを返す(他メンバーの個票は含めない)。
query(member_stats, "#tenantId = :tenantId") の 1 アクセスで集計する。
これが D4(事前計算)の目的。
クエリ: ?teamId=team_backend&period=6m
{
"scope": "org", // org | team(ロールに応じてサーバが決定)
"memberCount": 15,
"summary": {
"totalSessions": 412,
"totalElapsedMs": 356000000,
"selfReachRate": 0.61,
"gateDistribution": { "A": 0.18, "B": 0.43, "C": 0.39 }
},
"orgAxes": { "observe": 82, "localize": 65, "hypothesize": 70, "verify": 61, "fix": 69 },
"weakestAxis": "verify",
"insight": "検証の軸が組織全体で低い傾向です。仮説を確かめずに修正に進む場面が多い可能性があります。",
// lead / admin のみ。member には含めない(NFR-S6)
"members": [
{
"memberId": "usr_9d0e11", "displayName": "佐藤", "teamId": "team_backend",
"sessionCount": 38, "selfReachRate": 0.74, "growthRate": 12,
"axes": { "observe": 88, "localize": 71, "hypothesize": 76, "verify": 58, "fix": 72 },
"lastSessionAt": 1786500000000
}
]
}初期表示は
insight(組織の弱点)。 個人一覧はその下に置く。 何を最初に見せるかがツールの使われ方を決めるため (evaluation-model.md §5.2)。
組織設定 rankingEnabled が false(既定)なら 404。
{
"enabled": true,
"scope": "team",
"period": { "from": 1785000000000, "to": 1787000000000 },
"problemId": "01J8A7R2K50000000000000001",
"entries": [
{ "rank": 1, "displayName": "田中", "total": 94, "reachedGate": "A", "timeIndex": 1.8 },
{ "rank": 2, "displayName": "佐藤", "total": 88, "reachedGate": "A", "timeIndex": 1.2 }
],
"myRank": 5,
"totalParticipants": 12
}| 制約 | 内容 |
|---|---|
| 対象 | 演習モード(comparable: true)のみ。実務モードは対象外(NFR-F2) |
| 表示 | 上位のみ。下位は表示しない。自分の順位は myRank で個別に返す |
| 参加 | rankingOptIn が有効なら、参加表明したメンバーのみ |
ロール: lead 以上
{
"title": "新人研修 第1回 — エラーの読み方",
"problemIds": ["01J8A...", "01J8B...", "01J8C..."],
"targets": { "memberIds": [], "teamIds": ["team_newgrad"] },
"dueAt": 1786600000000
}レスポンス 201 Created
{ "assignmentId": "01J8XM5P3Q0000000000000001", "assignedCount": 10 }対象メンバーの人数分アイテムを作るため、10 名なら 10 アクセスを消費する。 大人数への一括割り当てはバッチとして分割する(100 名を超える場合)。
完了した実務セッションを匿名化して問題集に登録する。
リクエスト
{
"title": "非同期データ到着前のレンダリング",
"difficulty": "medium",
"category": "async",
"targetLevel": "junior",
"tags": ["react", "undefined", "非同期"],
"anonymizedErrorText": "...", // 自動マスキング結果を人が確認・修正したもの
"anonymizedCodeSnippet": "..."
}レスポンス 201 Created
{ "problemId": "01J8A7R2K50000000000000001", "status": "draft" }登録は必ず
draftから始まる。 自動匿名化の精度が未検証のため、publishedにするには人の確認を経る必要がある(未決 Q-13 / NFR-S2)。 社内の実エラーには顧客名・内部パス・識別子が含まれうる。
ロール: admin のみ。閲覧は監査ログに記録する(NFR-S9)。
{
"memberIds": ["usr_9d0e11"],
"period": { "from": 1775000000000, "to": 1790000000000 },
"format": "json" // json | csv(PDF は v2)
}レスポンス 200 OK — 構造は evaluation-model.md §7.1 に準拠。
{
"reports": [
{
"memberId": "usr_9d0e11",
"displayName": "佐藤",
"growth": { "before": 62, "after": 74, "delta": 12, "mostImproved": "localize", "stagnant": "verify" },
"currentAxes": { "observe": 88, "localize": 71, "hypothesize": 76, "verify": 58, "fix": 72 },
"orgAverage": { "observe": 82, "localize": 65, "hypothesize": 70, "verify": 61, "fix": 69 },
"gateDistribution": { "A": 0.24, "B": 0.51, "C": 0.25 },
"previousGateDistribution": { "A": 0.12, "B": 0.44, "C": 0.44 },
"volume": { "sessions": 38, "elapsedMs": 33120000, "assignmentCompletionRate": 0.92 },
"developmentSuggestion": "検証力が相対的に低い。仮説を立てた後、確かめずに修正に進む傾向がある。",
"basis": { "formulaDocUrl": "/docs/evaluation-model", "sessionIds": ["01J8W...", "..."] }
}
]
}このレスポンスは総合点フィールドを持たない。
growth を先頭に置き、5 軸内訳と組織平均を必ず併記する(NFR-F5)。
単一の点数だけで人を並べられる形にしないことが、この API の設計意図。
データストアにトランザクションがなく、Lambda には並行リクエストが届きうるため、 状態を変えるエンドポイントは冪等に設計する。
| エンドポイント | 冪等キー | 二重実行時の挙動 |
|---|---|---|
POST /answers |
questionId |
該当ターンに answeredAt があれば、記録済みの結果をそのまま返す |
POST /diagnose |
sessionId |
diagnosisStatus !== "pending" なら何もせず 200 |
POST /advance |
sessionId |
既に gate === "B" なら現在の設問を返す |
POST /reveal |
sessionId |
既に gate === "C" なら同じ開示内容を返す |
POST /conclusion |
sessionId + 本文ハッシュ |
直前と同一本文なら記録済みの判定を返す |
GET /report(初回生成) |
sessionId |
reports に既にあれば生成せず返す。member_stats の二重加算を防ぐ |
クライアント側は、レスポンスが返るまで送信ボタンを無効化する (それでもネットワーク再送はありうるため、サーバ側の冪等性が最終防御)。
GET /reportの冪等性が特に重要。 ここでmember_statsを更新するため、 二重実行するとセッション数とスコアが二重に加算され、評価データが壊れる。reportsアイテムの存在を先に確認してから集計を更新する。
| 対象 | 制限 |
|---|---|
POST /v1/sessions |
10 回 / 時 / メンバー |
POST /v1/sessions/:id/answers |
120 回 / 時 / メンバー |
POST /v1/assignments |
20 回 / 時 / 組織 |
| その他 | 600 回 / 時 / メンバー |
超過時は 429 と Retry-After ヘッダを返す。
v0.1 で実装しているのは
POST /v1/sessionsだけ(RATE_LIMIT_SESSIONS_PER_HOUR、既定 10)。判定はカウンタを持たずに行う。
sessionIdが ULID で作成時刻を含んでいるため、 新しい順に N 件引いて N 件目が 1 時間以内かを見れば足りる(下の「実装上の注意」の方針どおり)。 固定の 1 時間区切りではなくスライド窓で、N 件目が 1 時間を過ぎた時点で 1 枠空く。
MOCK_MODEでは無効にならない。 モックが差し替えるのは LLM だけで(ADR-014)、 レート制限はデータストア上の実データで判定する。画面を繰り返し試すと引っかかるため、 デモや動作確認ではRATE_LIMIT_SESSIONS_PER_HOURを上げておく。
Retry-Afterは画面に出す(「あと約 24 分で再開できます」)。 同一オリジン配信(ADR-012)なのでブラウザからそのまま読める。 別ホストに置いていたら CORS の既定でヘッダが見えず、Access-Control-Expose-Headersの設定が要るところだった。
実装上の注意: Lambda は状態を持てないため、カウンタはデータストアに置くことになるが、 それ自体がアクセス枠(E4)を消費して本末転倒になる。 v1 は既存アイテム内のカウンタで済む制限のみを実装する (
POST /answersの回数はセッションのturns.length、 セッション作成数はmember_statsの当日カウンタで判定できる)。 認証必須になったことで、匿名の大量アクセスという脅威自体が小さくなった。
評価データの閲覧は記録する。「誰が誰の評価を見たか」が追えることが、 P6(評価は人を裁く道具にしない)の担保になる。
| 記録対象 | 内容 |
|---|---|
GET /v1/org/dashboard(個票を含む場合) |
閲覧者 / 対象範囲 / 時刻 |
POST /v1/org/reports/evaluation |
出力者 / 対象メンバー / 期間 / 時刻 |
PATCH /v1/org/members/:id(ロール変更) |
実行者 / 変更内容 / 時刻 |
403 FORBIDDEN を返したアクセス |
試行者 / 対象 / 時刻 |
出力先は実行環境の標準ログ(構造化 JSON)。 組織管理者は自組織分の監査ログを閲覧できる(v2 で UI を提供)。
リクエスト / レスポンスの Zod スキーマは packages/shared/src/schemas/ に置き、
apps/function(検証)と apps/web(型付きクライアント)の両方が同じ定義を参照する。
packages/shared/src/schemas/
├── auth.ts # AuthContext / Role
├── session.ts # CreateSessionRequest / SessionPublic / Gate
├── question.ts # QuestionPublic(correctOptionId を持たない)
├── answer.ts # SubmitAnswerRequest / AnswerResult / PendingResult
├── reveal.ts # RevealPublic(★ここにだけ答えがある)
├── report.ts # ReportPublic / Score / ScoreExplanation
├── org.ts # OrgSettings / Member / DashboardResponse
├── problem.ts # Problem / Assignment
└── error.ts # ApiError
| 境界 | 実装 |
|---|---|
| 答えの境界 | QuestionPublic に correctOptionId を定義しない。答えを持つのは RevealPublic だけで、これを返すのは POST /reveal と GET /report のみ |
| テナントの境界 | ownerId / tenantId はブランド型にし、認証コンテキストからのみ生成する(data-model.md §6) |
どちらも「気をつける」で守るのではなく、間違った型が渡せない形にしておく。
QuestionPublic に correctOptionId を定義しないことが、
NFR-S3(答えを API に出さない)の実装上の担保となる。
{ "tenantId": "org_ac31f2", "memberId": "usr_9d0e11", "role": "member", "teamId": "team_backend" }