Compare commits

...

80 Commits

Author SHA1 Message Date
hongjr03 96e120e02c feat: add curated curriculum agent skills 2026-07-11 12:22:01 +08:00
hongjr03 1a892ccb54 chore: release hub 0.0.7 2026-07-11 02:42:35 +08:00
hongjr03 9d494f446f docs: clarify Feishu user profile permissions 2026-07-11 02:42:09 +08:00
hongjr03 5a65188b5d fix: allow arbitrary workspace file delivery 2026-07-11 02:41:14 +08:00
hongjr03 7fcb57013e fix: restore bounded agent sandbox execution 2026-07-11 02:34:56 +08:00
hongjr03 035c264179 fix: keep agent sandbox sockets on short paths 2026-07-11 02:22:19 +08:00
hongjr03 38d9a9c6cb fix: bootstrap fresh alpha silo 2026-07-11 01:40:20 +08:00
hongjr03 cb1a9e823a chore: release hub 0.0.3 2026-07-11 00:29:32 +08:00
hongjr03 9e954790dc feat: add deployable alpha silo 2026-07-11 00:25:45 +08:00
hongjr03 44557da499 feat: secure organization Feishu credentials 2026-07-10 23:13:11 +08:00
hongjr03 848f913597 feat: secure organization provider credentials 2026-07-10 22:04:43 +08:00
hongjr03 e049cb6880 fix: clear dependency security advisories 2026-07-10 18:59:03 +08:00
hongjr03 d730e51c3d fix: enforce active organization boundary 2026-07-10 18:42:12 +08:00
hongjr03 f07f280b8f fix: prove Linux Agent sandbox boundary 2026-07-10 17:59:56 +08:00
hongjr03 f4968d6657 fix: confine Agent and MCP data access 2026-07-10 16:38:08 +08:00
hongjr03 73fa28a178 feat: provision non-root Hub service 2026-07-10 15:59:35 +08:00
hongjr03 5f791c5ce4 chore: restore checker CI gate 2026-07-10 13:46:22 +08:00
hongjr03 420c1c40bc docs: define platform admin identity boundary 2026-07-10 13:36:35 +08:00
hongjr03 913a7ae5c0 docs: resolve product surface audit 2026-07-10 12:42:34 +08:00
hongjr03 599a2f6e66 docs: resolve backup recovery audit 2026-07-10 12:30:19 +08:00
hongjr03 1094ebd651 fix: let agents download Feishu message resources 2026-07-10 12:17:07 +08:00
hongjr03 4ca4afb141 docs: define SaaS capacity controls 2026-07-10 12:07:08 +08:00
hongjr03 16329df357 docs: resolve observability recovery audit 2026-07-10 11:10:32 +08:00
hongjr03 ec630b9151 docs: resolve run lifecycle audit 2026-07-10 03:16:05 +08:00
hongjr03 682bc70563 docs: resolve tenant security audit 2026-07-10 03:05:58 +08:00
hongjr03 5e412761d2 docs: resolve clean-host deployment audit 2026-07-10 02:51:29 +08:00
hongjr03 90cbd74562 docs: record checker format gate 2026-07-10 02:34:54 +08:00
hongjr03 2dc125aec1 docs: chart SaaS production readiness 2026-07-10 02:34:30 +08:00
hongjr03 b0eedfab05 test(cph-model): isolate version fixtures 2026-07-10 02:33:12 +08:00
hongjr03 008c8bcd09 feat(hub): add org sessions and usage admin APIs
List project agent sessions/runs and aggregate AgentRun cost and token
usage by project and optional folder for org admins.
2026-07-10 00:55:43 +08:00
hongjr03 683d5674d1 feat(hub): add org members, teams, and project team-access APIs
Manage memberships with last-OWNER protection, team lifecycle (archive
revokes TEAM grants), and TEAM→PROJECT access under org admin auth.
2026-07-10 00:55:34 +08:00
hongjr03 1f8510a47f feat(hub): add org project explorer admin APIs
Expose folder/project tree, create/move/rename/archive, and Feishu
binding archive under /api/org/:orgSlug for OWNER/ADMIN sessions.
2026-07-10 00:55:26 +08:00
hongjr03 c4f052efa2 feat(hub): add Feishu OAuth session for org admin
Introduce signed cookie sessions, Feishu web OAuth, requireOrgRole
guards, and the first org admin APIs (summary + project settings).
2026-07-10 00:55:19 +08:00
hongjr03 87e3d3f990 feat: let Feishu onboarding create projects in folders 2026-07-10 00:35:18 +08:00
hongjr03 519ea8144f feat: add Feishu project onboarding card 2026-07-10 00:32:09 +08:00
hongjr03 34e07e229f feat: add org admin project onboarding foundation 2026-07-10 00:25:00 +08:00
hongjr03 2b7aa3294f feat: add organization tenant model 2026-07-09 23:37:20 +08:00
hongjr03 5d315fff21 fix: enforce role tool whitelist 2026-07-09 20:48:03 +08:00
hongjr03 716101eed0 feat: add agent cost reporting 2026-07-09 20:29:56 +08:00
hongjr03 fc5a5365d8 fix: reply to Feishu trigger messages 2026-07-09 20:04:13 +08:00
hongjr03 fc0df7b823 fix: label Feishu trigger senders in agent prompts 2026-07-09 19:56:47 +08:00
hongjr03 f1d29176d3 feat: add slash command help 2026-07-09 19:43:13 +08:00
hongjr03 f260195439 fix: ack Feishu card actions before interrupt work 2026-07-09 19:26:57 +08:00
hongjr03 d01ea0e1cb feat: interrupt running agent via card button with confirm dialog
Add a  中断 button to live streaming cards; Feishu's native confirm
dialog is the confirmation step, so no extra card round-trip is needed.

- builder.ts: render interrupt action (danger button + confirm) while the
  run is live; new `interrupted` flag renders a 已中断 footer instead of
  完成/失败 on the complete card.
- streaming-card.ts: finish(text, { interrupted }) threads the flag into
  the final patch; overflow cards suppress the button.
- runner.ts: RunRequest gains abortController, passed to the SDK query;
  abort surfaces as RunStatus "interrupted" (no error) in the catch.
- trigger.ts: activeRuns registry maps runId → AbortController; onCardAction
  resolves the interrupt button, authorizes agent.cancel, aborts the run.
  Interrupted runs persist as CANCELED (spec RunState.canceled, terminal →
  lock released per ADR-0002).
- authorizer.ts: agent.cancel now consults PermissionSettings.agentCancel
  (MANAGE_ONLY/DISABLED) — previously only agentTrigger was honored, but
  spec/PermissionGrant deliberately splits these knobs ("谁能触发" ≠
  "谁能取消"). Default role remains MANAGE (Capability.normalCancel).

Tests: runner abort unit test, trigger interrupt + denied integration
tests, three agent.cancel authorization tests (manage allowed, edit denied,
DISABLED policy denies even manage). 26 files / 175 tests pass.
2026-07-09 18:59:31 +08:00
hongjr03 d6724e5a83 feat: streaming agent card with tool-use trace, reasoning panel, and tool results
Replace text-only PatchableTextStream with a unified StreamingAgentCard that
renders the full agent run lifecycle in a single Feishu interactive card:

- Tool-use panel: collapsible, shows each tool call with status (running/
  success/error), input summary, and result/error in code blocks
- Reasoning panel: collapsible, streams thinking text inline then collapses
  on completion
- Answer text: rendered via native Feishu markdown component (no post-row
  round-trip)

Runner now captures previously-discarded SDK events:
- thinking_delta → reasoning stream
- tool_use id + input on content_block_start / assistant message
- tool_result from user messages (was entirely unhandled)

New files:
- card/trace-store.ts: per-run in-memory tool-use trace with secret redaction
- card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text)
- card/streaming-card.ts: StreamingAgentCard controller (400ms throttled)

client.ts: add sendCard/patchCard raw JSON primitives
2026-07-09 17:18:40 +08:00
hongjr03 346aee5a68 fix: bind agent sessions to role 2026-07-09 16:06:06 +08:00
hongjr03 491fd13ca8 fix: resolve Feishu sender names via basic profile API 2026-07-08 23:59:17 +08:00
hongjr03 79505e6d44 fix: use startedAt instead of createdAt in trigger integration test
AgentRun model has startedAt, not createdAt. Codex introduced this
typo when refactoring the trigger queue integration tests.
2026-07-08 23:30:28 +08:00
hongjr03 a0df8b6bcf fix: repair streaming output broken by markdown rendering changes
Two bugs were introduced when sendTextMessage was changed to auto-detect
message format (text/post/interactive):

1. Message type mismatch: sendTextMessage could create a text or post
   message, but patchTextMessage only works on interactive cards.
   Fix: add sendInteractiveCardMessage for streaming create (always
   interactive, always patchable).

2. flushTextToSink state corruption: the text management after flush
   had a flawed appendedDuringFlush heuristic that could lose text or
   duplicate it. Fix: properly track sent vs unsent text by removing
   the sent prefix length, keeping only the last chunk for future patches.

Also fix finish() to flush pending text regardless of currentMessageId.
2026-07-08 17:48:55 +08:00
hongjr03 36c0801e6f feat: add per-project message trigger wait queue
- TriggerQueue: FIFO per-project queue, max 5 items, 5min TTL expiry
- Replace reject-when-locked with queue: users get position feedback
- Run completion auto-starts next queued trigger (skips expired items)
- /reset clears project queue; 60s periodic purge interval
- Add unit tests (10) for queue lifecycle and edge cases
2026-07-08 17:31:43 +08:00
hongjr03 4479f88f4f feat: add sender profile cache with LRU eviction
- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern
- resolveSenderName: API call with cache integration
- Trigger uses cache for approval card resolutions and audit metadata
- Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
2026-07-08 17:22:23 +08:00
hongjr03 2736006262 feat: add retry with exponential backoff for file send/download
- withRetry helper: configurable maxAttempts, baseDelayMs, shouldRetry
- sendFile: upload and message send wrapped with 3 retries, 1s/2s/4s backoff
- downloadMessageFile: wrapped with 3 retries
- Add unit tests (6) for retry behavior and sendFile retry integration
2026-07-08 17:15:30 +08:00
hongjr03 294d51dbfe feat: parse inbound post (rich text) messages
- parsePostMessage: full Feishu post payload parser with locale fallback,
  supports text/a/at/img/media/file/code_block/br/hr/emotion elements
- Trigger accepts post messages, extracts text + downloads attachments
- Post messages with attachments bypass batching, text-only posts batch
- Add unit tests (10) for all element types and edge cases
2026-07-08 17:05:51 +08:00
hongjr03 bef10149a9 feat: improve outbound message chunking with code-block-aware splitting
- Increase max message length from 4000 to 8000 chars
- Add splitAtBoundary: paragraph > newline > space priority, never split
  inside fenced code blocks (move split to before opening fence)
- Add sendLongText: sends multi-chunk long messages sequentially
- Update PatchableTextStream.flush to use splitAtBoundary
- Add unit tests (6) for splitting edge cases
2026-07-08 16:57:53 +08:00
hongjr03 4736610cf3 feat: add interactive approval/confirmation cards
- sendApprovalCard: interactive card with configurable buttons
- resolveCard: patch card to show resolution state (green/red)
- ApprovalManager: register/resolve lifecycle with 5min timeout
- request_approval MCP tool: agent can ask user for confirmation
- Wire onCardAction in trigger handler and server startup
- Add unit tests (5) for card JSON, manager, and routing
2026-07-08 16:47:31 +08:00
hongjr03 ad65d93b2e feat: add message burst debouncing (MessageBatcher)
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks,
  max 8 messages/4000 chars before immediate flush, per (chatId, sender) key
- Integrate into trigger: text messages enqueued, slash commands and
  file/image bypass batching, locked projects respond immediately
- Add unit tests (8) for batcher lifecycle and edge cases
2026-07-08 16:40:39 +08:00
hongjr03 6227e1931b feat: improve Feishu markdown rendering and processing status feedback
- Add buildOutboundPayload: detect markdown format, isolate code blocks,
  force plain text for tables, fallback from post to text on API rejection
- Add addReaction/removeReaction for processing status lifecycle
- Replace THUMBSUP with Typing→(remove on success | CrossMark on failure)
- Add unit tests for markdown rendering (15) and reactions (6)
2026-07-08 16:31:07 +08:00
hongjr03 a00e4f9871 fix(hub): 保留飞书回复上下文 2026-07-08 16:03:52 +08:00
hongjr03 a025d23af8 fix(hub): 启动前应用 Prisma 迁移 2026-07-08 15:45:12 +08:00
hongjr03 dfcc2d70f8 feat(hub): 支持教师团队权限 2026-07-08 15:42:53 +08:00
hongjr03 3f486232a8 feat(hub): 抽出运行时配置 2026-07-08 15:25:49 +08:00
hongjr03 a766d99573 fix(hub): 修复 Hub 部署脚本失败处理 2026-07-08 15:14:23 +08:00
hongjr03 1cfda3ccd2 ci(hub): 补齐 Hub 检查和健康 smoke 2026-07-08 15:14:06 +08:00
hongjr03 ead5cf04d6 fix(hub): 稳定 Feishu trigger 集成测试 2026-07-08 15:13:50 +08:00
hongjr03 ecbc2c8666 fix(hub): 对齐 ADR-0018 agent 执行面边界
runner.ts: 启用 SDK 内置沙箱(bubblewrap/seatbelt),写入限制在 workspace,
denyRead 敏感路径,failIfUnavailable 硬拒非沙箱运行。bypassPermissions 保留
(headless 无交互),沙箱是硬边界而非权限提示层。

install_service.sh: 默认 service user 从 root 改为 cph-hub;env 模板修正
OPENROUTER_API_KEY → ANTHROPIC_AUTH_TOKEN/ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY
(与 server.ts 实际读取一致);补 __BASE_DIR__ sed 替换。

cph-hub.service: AssertPathExists=/usr/bin/bwrap 强制沙箱依赖;NoNewPrivileges。
不加 ProtectSystem/ProtectHome(会破坏 cph render-cache 与 bwrap user-namespace)。

trigger.ts: 权限闸门去掉 if (senderOpenId !== '') 短路——缺 open_id 一律
fail-closed 拒绝,不再静默跳过;role gate 同步去掉冗余守卫(已由上游保证)。
顺手把 JSON.parse(msg.content) 的 inline cast 换成 Zod schema parse
(FileMessageContentSchema / TextMessageContentSchema)。

ADR-0018: 状态改为 Accepted+implemented,机制段写定 SDK 沙箱,网络决定为开放,
Open Questions 更新。
2026-07-08 13:11:14 +08:00
hongjr03 5b7fd70124 spec: 钉死 agent 执行面边界(ADR-0018)
ADR-0001/0002/0004 覆盖协作治理到 triggerAgent,但触发后 agent 在执行层
能干什么——读哪些文件、跑什么命令——无 ADR / spec 覆盖。ADR-0017 落地时
采用 bypassPermissions + 全量内置工具,agent 文件/shell 面对宿主无界;
workspace.ts 的 confine() 沙箱意图存在但被 ADR-0017 静默覆盖成死代码。

新增 ADR-0018 + Spec.System.AgentSurface 模块补这一层:
- AgentFileOp(run × path)+ Authorized 谓词(路径必须落在 run 工作区内)
- 与 Lock 正交:Lock 限定并发,Surface 限定波及面,都按 run × project 作用域
- 机制 OPEN(工具包装 / OS 沙箱 / SDK 钩子),契约只钉不变式
- ADR-0017 的 bypassPermissions + workspace.ts 死代码标为偏离契约,对齐为 follow-up
2026-07-08 12:37:37 +08:00
hongjr03 6178664696 fix(hub): send Feishu files explicitly 2026-07-08 11:48:25 +08:00
hongjr03 c73b41e1da fix(hub): resume Claude SDK sessions 2026-07-08 11:48:20 +08:00
hongjr03 012c8a7d89 fix(hub): default to OpenRouter Sonnet model 2026-07-08 11:48:16 +08:00
hongjr03 250f918346 chore: delete dead code 2026-07-08 02:37:44 +08:00
hongjr03 30d20421b3 fix(hub): busy 表情从 Hourglass 改为 OnIt(飞书支持的 emoji_type) 2026-07-08 02:22:05 +08:00
hongjr03 20bf7e271f fix(hub): 启动重置状态 + lazy-init 修复空白消息 + busy 改表情
三个修复:
1. 启动时清锁+标死run为FAILED(防重启后卡死)
2. 空白消息修复:lazy-init 改为第一个 text-delta 立即创建消息
  (不走 throttle);.then 加 fallback:没收到 text-delta 时用
  result.text 发完整回复
3. busy 提示从文本'项目正在处理中'改为表情回复

tsc rc=0。
2026-07-08 02:20:49 +08:00
hongjr03 eaa7fd4fd8 feat(hub): 文件收发 + 卡片回调 + 更好的流式
client.ts 重写:
- downloadMessageFile: 下载飞书消息里的文件/图片到本地
- sendFile: 上传文件到飞书 + 发送文件消息(msg_type=file)
- CardActionEvent 类型 + startFeishuListenerWithClient 加 onCardAction
  回调参数,注册 card.action.trigger 事件(消除了 'no card.action.trigger
  handle' 警告)
- 去掉未使用的 card builder 函数

trigger.ts:
- 处理 file/image 消息:下载到 workspace/.cph/inbox/,prompt 里告诉
  agent 文件路径
- 流式 throttle 从 800ms 降到 400ms,初始占位从 '…' 改为空(更干净)
- run 完成后扫描 workspace/build/ 下 5 分钟内的新文件,自动发送给
  老师(cph build 产出的 PDF 自动回传)

tsc rc=0。
2026-07-08 02:13:51 +08:00
hongjr03 1ad78dbcce test(hub): 适配 Claude Code SDK 的 mock 修复
helpers.ts: MockLanguageModel 加 doStream 实现(返回 text-delta +
finish part);runner.test.ts: 加 stubPrisma(必填字段)。
这些测试在 Claude Code SDK 迁移后还需要进一步适配,先提交 buffer。
2026-07-08 01:52:21 +08:00
hongjr03 085f7c7186 fix(hub): 去掉冗余 OPENROUTER_API_KEY,直接用 ANTHROPIC_AUTH_TOKEN 2026-07-08 01:48:01 +08:00
hongjr03 b9f50407be fix(hub): Claude Code SDK 经 OpenRouter Anthropic Skin,保留 provider-agnostic
我之前判断错了——OpenRouter 有 Anthropic Messages API 兼容端点
(https://openrouter.ai/api),Claude Code SDK 直连不需要代理。
ANTHROPIC_AUTH_TOKEN=OpenRouter key,ANTHROPIC_API_KEY='' 即可。

而且 ANTHROPIC_DEFAULT_SONNET_MODEL 可设 OpenRouter model ID
(z-ai/glm-4.7 等),所以 provider-agnostic 没丢。

ADR-0017 重写:从'Anthropic 专有'改为'Claude Code SDK via OpenRouter,
保留 provider-agnostic'。tsc rc=0。
2026-07-08 01:46:09 +08:00
hongjr03 082562ca08 docs(adr): ADR-0017 重写——从 provider-agnostic 改为 Claude Code SDK
放弃 provider-agnostic,用 @anthropic-ai/claude-agent-sdk。代价:不能走
OpenRouter(GLM 等),需 Anthropic API key。收益:compaction、工具审批、
partial message 流式、Claude-to-IM 原生兼容。迁移路径:如果将来需要
provider-agnostic,用 streamText + SSE 转换层(~100 行)替回 query()。
2026-07-08 01:40:28 +08:00
hongjr03 4bd5b03bb1 refactor(hub): Vercel AI SDK → Claude Code SDK
用 @anthropic-ai/claude-agent-sdk 的 query() 替换 Vercel AI SDK 的
streamText。SDK 自带 Read/Write/Bash/Glob/Grep 工具——read_file/
write_file/list_files/cph_check/cph_build 全部不需要了,Bash 跑
cph 直接。

UX 同时改成 workbuddy 风格:
- 收到 @bot → 表情回复(👍)确认收到,不发'已开始处理'
- agent 回复作为文本流式发送(text-as-card patch,markdown 渲染)
- 超长自动续发新消息,不截断
- 完成/出错不再额外发状态消息
- 去掉卡片(model选择器/取消按钮)

ADR-0017 更新:放弃 provider-agnostic,Claude Code SDK 是 Anthropic
专有。换来:compaction、工具审批、partial message 流式、成熟 agent
loop。

tsc rc=0。旧文件(workspace.ts/cph.ts/provider.ts/tools.ts)暂留,
下个 commit 清理。
2026-07-08 01:36:59 +08:00
hongjr03 1abcc495f4 test(hub): 真实 model 集成测试 + Gitea CI workflow
real-model.test.ts: 用真 OpenRouter model + 真 cph + 真 workspace 跑 agent
  tool-calls 循环,验证 MockProvider 测不到的路径(model 返回 tool_calls →
  dispatch → tool_result 回传 → 继续/stop)。没 OPENROUTER_API_KEY 时自动
  skip,不阻塞 CI;有 key 时自动跑。
  两个 test:(1) list files → cph check → 回答;(2) write file → read back。
hub-check.yml: Gitea CI workflow,对齐 checker-check/spec-check 的纪律:
  npm ci → prisma generate → tsc → prisma validate → 装 cph → vitest unit →
  vitest integration(postgres service container)→ real-model(可选,从
  secrets 注入 key)。
全 suite: 60 passed + 2 skipped(无 key)。tsc rc=0。
2026-07-07 23:45:59 +08:00
hongjr03 e7924f78d5 feat(hub): 结构化运行历史(A-3) + lock 心跳(A-5)
A-3 结构化历史:
- 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆)
- 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation)
- runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages
- workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录
- runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange
- ToolContext 加 onFileChange sink 字段
- trigger.ts 接线 runId/sessionId/prisma 到 RunRequest

A-5 lock 心跳:
- ProjectAgentLock 加 heartbeatAt 列
- runner.ts onStepFinish 回调:每步 model step 后更新 heartbeatAt(吞错,lock 可能被 force-release)

60 tests 全过(tsc 干净)。
2026-07-07 22:20:30 +08:00
263 changed files with 35236 additions and 1066 deletions
+10 -2
View File
@@ -19,8 +19,10 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain (stable + clippy + rustfmt)
uses: dtolnay/rust-toolchain@stable
# Keep local rustup, rustfmt output, clippy, and CI on one reproducible
# compiler release. Update this together with rust-toolchain.toml.
- name: Install Rust toolchain (1.92.0 + clippy + rustfmt)
uses: dtolnay/rust-toolchain@1.92.0
with:
components: clippy, rustfmt
@@ -35,6 +37,12 @@ jobs:
restore-keys: |
cargo-${{ runner.os }}-
- name: Install cargo-audit 0.22.2
run: cargo install cargo-audit --version 0.22.2 --locked
- name: Audit Rust vulnerabilities and unsoundness
run: cargo audit --deny unsound
# Fonts for typst compilation (WU-4 onward): the engineering files are
# CJK-heavy, so the compile-check / build tests need a CJK font present.
# Harmless before those crates exist.
+121
View File
@@ -0,0 +1,121 @@
name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
on:
push:
pull_request:
workflow_dispatch:
jobs:
hub-check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s
--health-timeout 5s
--health-retries 20
defaults:
run:
working-directory: hub
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: hub/package-lock.json
- name: Install dependencies
run: npm ci
- name: Audit production Node dependencies
run: npm run audit:production
- name: Install Linux sandbox dependency
run: sudo apt-get update && sudo apt-get install --yes bubblewrap socat
- name: Wait for Postgres
run: |
node <<'NODE'
const net = require("node:net");
const deadline = Date.now() + 60000;
function tryConnect() {
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
socket.once("connect", () => {
socket.end();
process.exit(0);
});
socket.once("error", () => {
socket.destroy();
if (Date.now() > deadline) {
console.error("Postgres did not become reachable at 127.0.0.1:5432");
process.exit(1);
}
setTimeout(tryConnect, 1000);
});
}
tryConnect();
NODE
# Prisma client must be generated before tsc can check imports from
# @prisma/client. Stub DATABASE_URL so prisma generate works.
- name: Generate Prisma client
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma generate --schema prisma/schema.prisma
- name: Type-check
run: npx tsc -p tsconfig.json --noEmit
- name: Build
run: npm run build
- name: Validate Prisma schema
run: DATABASE_URL="postgresql://stub:stub@127.0.0.1:5432/stub" npx prisma validate --schema prisma/schema.prisma
- name: Install cph binary
run: |
cd ..
cargo install --path crates/cph-cli --locked
- name: Prove real Claude SDK Bash sandbox boundary
run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
CPH_SANDBOX_TEST_ROOT=/w/t \
/usr/bin/setpriv --no-new-privs \
npx vitest run test/integration/agent-sandbox-linux.test.ts
- name: Run unit tests
run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is set up as a service container below.
- name: Run integration tests (mock provider, real prisma + cph)
run: |
npx prisma migrate deploy --schema prisma/schema.prisma
npx vitest run test/integration \
--exclude test/integration/real-model.test.ts \
--exclude test/integration/agent-sandbox-linux.test.ts
env:
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
- name: Run real-model integration tests (optional, needs secret)
run: npx vitest run test/integration/real-model.test.ts
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
RUN_REAL_MODEL_TESTS: ${{ vars.RUN_REAL_MODEL_TESTS }}
@@ -0,0 +1,325 @@
# Backup, migration, and disaster-recovery audit
## Verdict
The current Hub has no production backup or restore control plane. PostgreSQL
can be dumped and restored manually, but the service's authoritative state is
split between PostgreSQL and mutable Project workspaces, with no shared
consistency point, backup catalog, off-host retention, restore workflow,
recovery objectives, or recovery drill.
The current deployment path can also delete customer workspaces when the
documented relative workspace root is used: `deploy_platform.sh` applies
`rsync --delete` to the live Hub directory while `.env.example` places
workspaces below `./data/project-workspaces`. Project creation has the same
cross-store weakness at a smaller scale: it creates the workspace before the
database transaction and leaves an orphan directory when that transaction
fails.
Migration safety is incomplete even though the current PostgreSQL migration
engine rolled back a deliberately failing migration's DDL in the executed
probe. The failed migration record blocks later migrations, migrations execute
inside every service start, no compatibility declaration or production-data
rehearsal exists, and current history contains destructive changes that make an
old application query fail after schema upgrade. Application rollback is
therefore not database rollback.
## Contract and persistent-state baseline
The audit preserves the accepted contracts rather than inventing missing
product policy:
- `Organization` is the tenant root, but `User` is global and can belong to
multiple Organizations. A selective Organization restore is not equivalent
to restoring a self-contained database schema.
- Project workspaces are authoritative curriculum-engineering files bounded by
ADR-0018. PostgreSQL stores their absolute `Project.workspaceDir` locations;
it does not store their file content.
- Run, provider cursor, ingress, delivery, audit, and workspace commit/recovery
semantics remain split across the unresolved lifecycle tickets. A backup
design cannot silently choose their crash behavior.
- ADR-0022 requires storage ceilings and emergency `DRAIN`/`STOP_NOW`, but does
not decide backup frequency, retention, recovery objectives, or tenant-level
restore scope.
- Audit retention is still `OPEN`; regulatory backup retention and deletion
obligations require product/legal input already recorded in the map's Fog.
The recovery set has at least four classes:
| State | Current location | Recovery significance |
| --- | --- | --- |
| Tenant and workflow state | PostgreSQL | Organizations, identities, membership, permissions, Feishu bindings, sessions, runs, locks, receipts, usage facts, and audit records |
| Customer artifacts | `HUB_PROJECT_WORKSPACE_ROOT` | Project source files, generated artifacts, inbound attachments, and any workspace-local session/history files |
| Credentials and configuration | `/srv/.../.secrets/platform.env` today; future org-scoped encrypted connections plus external key material | Database/Feishu/provider access and ability to decrypt restored Organization credentials |
| Release identity | application release, Prisma migrations, `cph`, runtime/toolchain manifest | Required to start a schema-compatible application and reproduce outputs after restore |
Caches, build output, and reinstallable dependencies should be classified as
regenerable rather than silently mixed into the protected recovery set.
## Executed evidence
All probes used isolated temporary paths or databases. No production or shared
test data was modified.
### 1. The deployment rsync pattern deletes an in-tree workspace
A temporary live Hub directory was given the documented workspace shape and a
sentinel customer file, then the exact deployment rsync include/exclude pattern
was applied:
```text
rsync -a --delete \
--exclude node_modules --exclude dist --exclude .env \
hub/ <temporary-live-hub>/
workspace_survived=false
reproduced: deploy rsync pattern deletes an in-tree workspace root
```
This is deterministic: `data/` is absent from the release source and is not an
exclude, so `--delete` removes it from the destination. The root cause is that
the documented persistent path is nested inside the mutable release tree and
the deploy contract does not distinguish release artifacts from state.
### 2. A rejected Project transaction leaves an orphan workspace
A minimal Node harness called the real built `createProjectFromOrgAdmin` with a
valid Organization admin and a missing Folder. The fake Prisma boundary rejected
inside the same transaction seam used by production. The harness asserted that
the Organization workspace directory must remain empty and was run twice:
```text
node .scratch/saas-production-readiness/probes/orphan-workspace.mjs
orphan_workspace_entries=project_4aa0b6792c4547059d2d92b505b9299e
exit_code=1
orphan_workspace_entries=project_190e21afb8854244872b1241a600aa58
exit_code=1
```
`createManagedProject` calls `mkdir` before `prisma.$transaction`; the failure
path has no compensating removal, staged rename, or orphan reconciler. The
throwaway harness was removed after reproduction.
### 3. PostgreSQL rolled back failed migration DDL but retained a blocking failure
An isolated database and throwaway Prisma project applied one migration that
created a table, divided by zero, then would have created another table. The
real repository Prisma CLI returned `P3018`:
```text
migration_exit=1
before_failure=missing
after_failure=missing
20260710000000_partial_failure|finished=false|rolled_back=false|steps=0|logs=true
```
This specific PostgreSQL/Prisma 6.19.3 path did **not** leave partial DDL, so the
audit does not claim that it did. It did leave an unfinished
`_prisma_migrations` row, and Prisma's error explicitly refused new migrations
until operator recovery. Because systemd runs `migrate deploy` as
`ExecStartPre`, the application cannot start past that state. The throwaway
database and migration were removed. This one multi-statement result is not a
general atomicity guarantee: Prisma's first-party guidance says PostgreSQL
migrations are not transaction-wrapped by default, so each migration must make
its transaction and partial-failure behavior explicit.
### 4. The current schema is not backward-compatible with an older application
The application immediately before the typed-principal migration queried
`PermissionGrant.principal`. That migration drops the column in favor of
`principalType` plus `principalId`. Running the old read against the current
test database produced:
```text
ERROR: column "principal" does not exist
HINT: Perhaps you meant to reference the column "PermissionGrant.principalId".
old_query_exit=1
```
This is direct evidence that switching the application symlink back after that
migration cannot restore service. The migration history also deletes duplicate
grant rows and later drops `ExternalPrincipalMembership.source`; there are no
downgrade migrations.
### 5. A database-only custom dump restores cleanly
As a positive baseline, `pg_dump --format=custom` captured the current
`cph_hub_test` database and `pg_restore --exit-on-error --no-owner` restored it
into a new isolated database:
```text
restored_tables=24
finished_migrations=9
failed_migrations=0
dump_bytes=76113
```
All nine finished migration checksums in the source database also matched the
checked-in migration files. This proves a manual logical database round trip on
the current PostgreSQL 14.20 workstation. It does not prove workspace,
credentials, global database roles, production volume, cross-version restore,
application readiness, or an operational RPO/RTO.
## Critical findings
### 1. No backup, retention, or restore mechanism exists
The repository has no backup/restore command, systemd timer, off-host target,
encryption/immutability policy, retention policy, backup manifest/checksum,
success/failure alert, restore command, or scheduled recovery drill. The
installer assumes PostgreSQL and its role/database already exist. A database
dump alone does not reproduce those global prerequisites or the filesystem and
secret state above.
There is also no decided RPO, RTO, backup frequency, retention duration,
geographic copy rule, deletion-in-backups rule, or ownership/escalation path.
Those values are product and operations decisions, not safe constants to infer
from the single-host topology.
### 2. Persistent workspaces are inside the documented release tree
`.env.example` recommends `./data/project-workspaces`; systemd's working
directory is the live Hub directory; deployment mutates that same directory
with `rsync --delete`. No preflight forbids an in-release workspace root and no
exclude preserves it. This can turn a routine deploy into irreversible customer
data loss before build, migration, restart, or readiness runs.
The persistent workspace root must live outside every release directory, have
explicit ownership/mode and capacity, and be impossible for release retention
or `rsync --delete` to traverse. Immutable release work already exists as a
separate ticket, but its acceptance test must include this sentinel case.
### 3. PostgreSQL and workspaces have no consistency boundary
PostgreSQL can provide a transactionally consistent database snapshot, but the
Hub mutates workspace files independently and has no generation, journal,
commit marker, snapshot ID, or shared checkpoint that binds filesystem content
to the corresponding database state. A database dump and filesystem copy taken
at different instants can disagree about Project existence, active runs,
provider cursors, delivered messages, and file effects.
Project creation already demonstrates the same missing compensation boundary.
A replacement host has an additional problem: `Project.workspaceDir` is an
absolute path and runtime paths from the database are used directly; restore
has no same-mount validation, safe rebasing operation, or missing/orphan scan.
For the initial single-host topology, a quiesced backup checkpoint is the
smallest credible route: stop new admission, drain/cancel according to the
accepted run contract, reach durable zero in-flight work, capture database and
workspace artifacts under one manifest, then resume. An online scheme would
need a newly designed journal/generation protocol; it must not be implied by
two independent copy commands.
### 4. Schema mutation is coupled to service start
Both `npm start` and the systemd unit execute `prisma migrate deploy`; every
ordinary restart can therefore become a schema rollout. The release path has no
separate migration lock/gate, `migrate status` check, production-data rehearsal,
duration/lock budget, pre-migration recovery checkpoint, or compatibility
manifest. CI only applies the full migration history to a fresh empty
PostgreSQL database, so it does not exercise real upgrade volume, legacy values,
old/new application overlap, failure recovery, or rollback compatibility.
The current failed-migration behavior is fail-closed, which is better than
starting on an unknown schema, but there is no operator procedure for inspecting
the failure, deciding roll-forward versus mark-rolled-back, restoring the
checkpoint, or preventing a restart loop. Existing destructive migrations prove
that application rollback cannot be assumed safe.
### 5. Restore has no isolated verification or cutover gate
There is no procedure that restores into a non-production environment with
outbound Feishu/provider traffic disabled, verifies backup checksums and
migration state, validates workspace paths/content/permissions, checks tenant
relationships, decrypts a sample org connection, reconciles non-terminal work,
runs application readiness and representative journeys, and measures recovery
time/point before controlled cutover.
Starting the current server is not a neutral verifier: startup deletes all
Project locks and independently marks only `ACTIVE` runs failed. Restored data
must first pass the durable lifecycle reconciliation designed by the run-state
tickets; otherwise validation itself mutates the evidence and can leave
`WAITING_FOR_USER` inconsistent.
### 6. Full-service and Organization-selective recovery are different products
A full-service restore can recreate the shared database and all workspaces.
Restoring one Organization is much harder: users are global and may belong to
multiple Organizations, permission/audit/history rows span many related tables,
workspace paths are external, and Feishu/provider connections have external
state. The repository has no export/import identity mapping or conflict policy.
The initial production gate must at least prove full-service disaster recovery.
Whether Organization-selective restore/export is promised, and how retained
backups interact with customer deletion, remains an explicit product/legal
decision rather than an accidental property of `pg_restore` filters.
## Required recovery contract
Production readiness requires the following guarantees, with exact numerical
objectives supplied by the pending policy decision:
1. **Declared objectives and ownership.** RPO/RTO, backup frequency, retention,
copy locations, encryption/key custody, immutability, deletion handling,
full-service versus Organization restore scope, operator ownership, and
escalation are documented and testable.
2. **Complete backup set.** One cataloged manifest identifies release and
migration checksums, PostgreSQL artifact and global prerequisites, workspace
snapshot, configuration inventory, encrypted org-secret data, external key
recovery material, timestamps, sizes, and cryptographic checksums. Backup
data and decryption material are not kept only on the protected host or in
the same failure domain.
3. **One consistency point.** A backup is taken only after an observable
quiesce/checkpoint protocol, or by a proven online generation protocol that
binds database state and workspace effects. Accepted input, active runs,
queues, deliveries, locks, and provider cursors have explicit recovery
semantics.
4. **Safe migration rollout.** Migration is a release phase, not an incidental
service start. The gate checks status/checksums, rehearses on representative
production-shaped data, records old/new application compatibility, uses
expand-and-contract for destructive changes, requires a recent verified
recovery checkpoint, and provides a tested failed-migration procedure.
5. **Isolated restore verification.** Every backup class is periodically
restored to a clean isolated host/database with outbound integrations off.
Verification checks integrity, schema/migration state, global database
prerequisites, workspace completeness/path ownership, secret decryption,
tenant isolation, lifecycle reconciliation, application readiness, and
representative admin/Feishu/agent journeys.
6. **Observable operation.** Backup age, duration, artifact/checksum status,
off-host replication, retention deletion, WAL/archive continuity if used,
restore-drill result, measured RPO/RTO, and failures are logged/metricized
and alerted with bounded cardinality.
7. **Controlled cutover and fallback.** Disaster recovery has an explicit
authority, traffic/workload brake, DNS/proxy/Feishu reconnect sequence,
duplicate/gap checks, rollback/fallback point, verification checklist, and
incident evidence record.
## Required release evidence
- A deploy test proves an in-tree or release-overlapping workspace root is
rejected before any rsync/switch, while persistent sentinel files survive
build failure, successful release, rollback, and release pruning.
- Project creation, archive/deletion, failed transaction, crash, and restore
tests prove database/workspace allocation has no silent missing or orphan
state; the reconciler reports rather than hides inconsistencies.
- Every checked-in migration is rehearsed from the previous production-shaped
snapshot, with old/new compatibility and lock/runtime evidence. A deliberately
failed migration exercises the documented recovery route.
- The backup implementation proves completeness, checksum verification,
encryption, off-host copy, retention, monitoring, and fail-closed partial
artifact cleanup.
- A clean isolated host restores a representative database, workspaces,
secrets, and compatible release; lifecycle reconciliation and critical tenant
journeys pass with outbound integrations controlled.
- Scheduled drills publish achieved recovery point and recovery time against the
accepted objectives, and alert when no recent successful drill exists.
## Primary-source interpretation
The official PostgreSQL, Prisma, and systemd behavior supporting this audit is
collected with claim-level links in
[Backup and migration primary sources](./backup-migration-primary-sources.md).
That note distinguishes what a PostgreSQL-consistent dump proves from the
cross-store guarantees the Hub must build itself.
@@ -0,0 +1,574 @@
# Backup, migration, and disaster-recovery primary-source research
This note is the primary-source evidence pack for the Wayfinder ticket
“Audit backup, migration, and disaster recovery safety.” External claims use
only PostgreSQL, Prisma, systemd, and rsync first-party documentation. Project
recommendations are explicitly labelled as derived implications rather than
upstream guarantees.
PostgreSQL links below use `docs/current`, which was PostgreSQL 18 when this
research was performed on 2026-07-10. A production implementation must pin the
deployed PostgreSQL major and verify the same pages for that major. The Hub
currently depends on Prisma `^6.19.3`, so Prisma 6-specific command examples
remain relevant even where the live documentation also describes Prisma 7.
## Executive conclusions
1. `pg_dump` can take a consistent **single-database** snapshot while ordinary
reads and writes continue, but it does not include the Hub's workspace tree,
cluster-global roles/tablespaces, or a WAL chain. PostgreSQL now explicitly
says it is generally not the right regular production-backup mechanism
outside simple cases. It is a useful logical/export layer, not proof of
whole-service disaster recovery.
([`pg_dump` description](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION),
[continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
2. Database PITR requires a physical base backup plus a continuous, unbroken
WAL sequence from the backup start through the recovery target. A successful
base-backup command alone does not prove that chain exists.
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING),
[making a base backup](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-BASE-BACKUP))
3. `pg_verifybackup` is necessary but not sufficient: PostgreSQL explicitly
requires test restores followed by checks that the restored database works
and contains the expected data.
([`pg_verifybackup`](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
4. Prisma `migrate deploy` applies pending migrations, but it does not detect
schema drift and, on PostgreSQL, Prisma does not wrap a migration in a
transaction by default. A failed migration can therefore leave partial
schema changes requiring explicit operator recovery.
([Prisma production workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production#production-and-testing-environments),
[Prisma transactional behaviour](https://www.prisma.io/blog/prisma-migrate-dx-primitives#what-if-schema-migrations-were-atomic),
[failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration))
5. systemd supplies process lifecycle orchestration, not an application-level
consistency barrier. It sends the configured termination signal, waits up
to `TimeoutStopSec=`, then can forcibly kill remaining processes; a
successful `systemctl stop` does not prove that an `ExecStop=` command or
application drain succeeded.
([`TimeoutStopSec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#TimeoutStopSec=),
[`systemctl stop`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#stop%20PATTERN%E2%80%A6))
6. The Hub persists one logical service state across two stores: PostgreSQL
records `Project.workspaceDir`, runs, messages, locks, and file-change audit
rows, while workspaces contain project files. A workspace transcript helper
exists but is not wired into the production runner. PostgreSQL's snapshot/
PITR guarantees cover only PostgreSQL. A whole-service recovery point
therefore requires an application-defined database/workspace checkpoint or
a journaled/versioned reconciliation protocol; neither PostgreSQL nor
systemd creates one automatically.
([Hub schema](../../../hub/prisma/schema.prisma),
[workspace transcript helper](../../../hub/src/agent/transcript.ts),
[`pg_dump` scope](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION))
## Current repository facts that make the boundaries material
- The systemd unit runs `prisma migrate deploy` as `ExecStartPre=` before every
service start and uses `Restart=on-failure`.
([`cph-hub.service`](../../../hub/deploy/cph-hub.service))
- The deploy script updates the live Hub directory using
`rsync -az --delete`, builds in that same directory, restarts the unit, and
accepts a constant liveness endpoint as deployment success.
([`deploy_platform.sh`](../../../hub/deploy/deploy_platform.sh))
- `Project.workspaceDir` is database state, but the referenced directory holds
independently mutable tenant content. A workspace JSONL transcript helper
exists but is not wired into the production runner; file contents still live
in the workspace while the database separately carries run/message/file-change
facts.
([Hub schema](../../../hub/prisma/schema.prisma),
[workspace transcript helper](../../../hub/src/agent/transcript.ts))
These are project facts, not upstream guarantees. The sections below establish
what the upstream tools do and, critically, what they do not do.
## PostgreSQL logical dump and restore
### Snapshot and concurrent-write semantics
- `pg_dump` makes a consistent export even while the database is being used;
it does not block ordinary readers or writers. It dumps only one database.
Cluster-global objects such as roles and tablespaces require `pg_dumpall` or
`pg_dumpall --globals-only`.
([`pg_dump` description](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION),
[whole-cluster logical dumps](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-ALL))
- `pg_dumpall` invokes `pg_dump` once per database. Each database is internally
consistent, but the snapshots are not synchronized across databases.
([whole-cluster logical dumps](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-ALL))
- A parallel dump uses directory format, opens `njobs + 1` connections, and
uses synchronized snapshots so every worker sees the same data set. The
connection count and additional server load are part of its capacity cost.
([`pg_dump --jobs`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
- Concurrent DDL can still interfere. The parallel-dump leader holds
`ACCESS SHARE` locks; if a later exclusive-lock request queues ahead of a
worker, that worker uses `NOWAIT` and aborts the dump rather than deadlocking.
`--lock-wait-timeout` can bound the initial lock wait.
([`pg_dump --jobs` and lock behaviour](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
- The ordinary snapshot is suitable for disaster-recovery dump semantics, but
it need not correspond to a serial execution of all transactions that later
commit. `--serializable-deferrable` waits for an anomaly-free snapshot;
PostgreSQL says that option is not beneficial for a dump intended only for
disaster recovery, but it can matter for a continuously updated reporting
copy.
([`--serializable-deferrable`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
- The default waits for dump files to be written safely to disk. Production
backups must not use `--no-sync`, because an immediate OS crash can otherwise
leave a corrupt dump.
([`pg_dump --no-sync`](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-OPTIONS))
Derived Hub implications:
- An online database dump may be internally valid while referring to a
`workspaceDir` whose files are from another instant. “`pg_dump` succeeded” is
therefore not a whole-Hub consistency claim.
- Backup scheduling must either serialize against schema migration/DDL or
treat any lock wait, abort, non-zero exit, and stderr warning as a failed
backup. A partially written archive must never be published as restorable.
- If the deployed cluster contains more than the Hub database, a single Hub
dump is not a cluster backup. Even `pg_dumpall` does not create a single
cross-database snapshot.
### Restore error and atomicity semantics
- `pg_restore` defaults to continuing after SQL errors and reports an error
count at the end. `--exit-on-error` is required for fail-fast behaviour.
`--single-transaction` wraps all emitted commands in `BEGIN`/`COMMIT`, makes
the restore all-or-nothing, and implies `--exit-on-error`.
([`pg_restore` error and transaction options](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
- Parallel restore uses separate connections for data loading, index creation,
and constraint creation. It supports only custom/directory archives and
cannot be combined with `--single-transaction`; it is not a whole-restore
atomic operation.
([`pg_restore --jobs`](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
- A single giant restore transaction can be impractical because it locks every
restored object and may exhaust the lock table. PostgreSQL 18 offers
`--transaction-size=N` as a bounded intermediate option; it implies
`--exit-on-error` but is not all-or-nothing.
([`pg_restore --transaction-size`](https://www.postgresql.org/docs/current/app-pgrestore.html#APP-PGRESTORE-OPTIONS))
- Plain SQL restored by `psql` also continues after errors unless
`ON_ERROR_STOP` is enabled; a single transaction is separately required for
all-or-nothing behaviour.
([restoring an SQL dump](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-RESTORE))
- PostgreSQL recommends restoring into a truly empty database based on
`template0`; otherwise local additions in `template1` can cause duplicate
definition errors. Required roles must exist before owner/ACL restoration.
([restoring an SQL dump](https://www.postgresql.org/docs/current/backup-dump.html#BACKUP-DUMP-RESTORE))
- A dump can execute arbitrary code selected by superusers of the source
database. Partial dump/restore does not remove that trust boundary.
([`pg_dump` security warning](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-DESCRIPTION))
- A `pg_dump` binary can dump older servers but refuses a server newer than its
own major version. Output is intended to load into newer PostgreSQL versions,
but loading into an older major is not guaranteed.
([`pg_dump` cross-version notes](https://www.postgresql.org/docs/current/app-pgdump.html#PG-DUMP-NOTES))
Derived Hub implications:
- Restore should target a new isolated database/cluster. If a non-atomic or
parallel restore reports any error, discard that target and start again;
never promote a partially restored database.
- The runbook must record the restore atomicity strategy and exact PostgreSQL
client/server versions. A green process exit without explicit fail-fast
options is insufficient evidence.
- A restored database must remain disconnected from real Feishu/provider
side-effect endpoints until application-level validation completes.
## Physical backup, WAL, and PITR
### Base backups
- `pg_basebackup` backs up a running PostgreSQL cluster without blocking other
clients. A full backup is an exact copy of the entire cluster and can seed
PITR or a standby; it cannot select one database or object.
([`pg_basebackup` description](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- It uses the replication protocol. The account needs `REPLICATION` or
superuser privilege, `pg_hba.conf` must permit it, and `max_wal_senders`
needs capacity for the backup and, when streaming WAL, another connection.
([`pg_basebackup` requirements](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- Default `-X stream` obtains WAL over a second connection while copying. With
`-X fetch`, WAL is collected only at the end; if required WAL was recycled,
the backup fails and is unusable.
([`pg_basebackup --wal-method`](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- The default waits for backup files to be durable. `--no-sync` is explicitly
unsuitable for production because an OS crash can corrupt the backup.
([`pg_basebackup --no-sync`](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
- A manifest is generated by default, with per-file checksums. A SHA manifest
can detect malicious modification only if the manifest itself is held in a
separate trusted location or otherwise authenticated.
([`pg_basebackup` manifest options](https://www.postgresql.org/docs/current/app-pgbasebackup.html#APP-PGBASEBACKUP))
Derived minimum backup record:
- source cluster/system identifier and PostgreSQL version;
- backup tool version, start/end time, start/end LSN and WAL range;
- manifest digest and its independently protected location;
- destination object/version, byte count, exit status, and verification state;
- an explicit `VALID`, `INVALID`, or `QUARANTINED` state so a failed leftover
directory cannot enter the retention set merely because files exist.
### WAL archival and recovery
- PITR combines a physical backup with WAL replay. It can stop at a chosen time
and reach a consistent database state. Logical dumps do not contain enough
information for WAL replay.
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
- Successful recovery requires a continuous sequence of archived WAL extending
back at least to the base-backup start. PostgreSQL tells operators to set up
and test WAL archiving **before** taking the first base backup.
([continuous archiving overview](https://www.postgresql.org/docs/current/continuous-archiving.html#CONTINUOUS-ARCHIVING))
- The archive command must return zero if and only if durable archival
succeeded. Non-zero causes retry. Archive implementations should refuse to
overwrite an existing file; a repeated name may report success only when the
existing durable content is identical, and differing content must fail.
([WAL archiving](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- Repeated archive failure accumulates WAL in `pg_wal`; filling that filesystem
causes PostgreSQL to panic and remain offline until space is freed. PostgreSQL
explicitly advises monitoring archival progress.
([WAL archiving failure behaviour](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- WAL is archived after a segment is completed, so low traffic can make the
last committed change wait a long time for archival. `archive_timeout` can
bound that age by forcing segment switches, at the cost of archive growth.
([WAL archiving latency](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- WAL archival does not restore manually edited PostgreSQL configuration files
(`postgresql.conf`, `pg_hba.conf`, `pg_ident.conf`); those need a separate
file backup.
([WAL archiving caveat](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
- PostgreSQL's recovery procedure keeps clients out, restores the base backup,
configures `restore_command` and `recovery.signal`, replays WAL, and requires
inspection of database contents before restoring ordinary access.
([PITR recovery procedure](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-PITR-RECOVERY))
- Recovery can target a timestamp, LSN, transaction ID, named restore point, or
the earliest consistent point. Only one target may be set. Timeline history
is required to navigate recovery branches, and a configured target not
reached before WAL exhaustion is a fatal recovery failure.
([recovery target settings](https://www.postgresql.org/docs/current/runtime-config-wal.html#RUNTIME-CONFIG-WAL-RECOVERY-TARGET),
[timelines](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-TIMELINES))
Derived Hub implications:
- The database RPO is the age of the newest **durably archived, continuous**
WAL position, not the age of the newest base backup and not merely the name
in `last_archived_wal`.
- Archive keys need a collision-proof cluster/system-identifier namespace and
create-if-absent semantics; unrelated clusters must never overwrite a shared
WAL name.
- Monitoring must cover archive failures, time since durable success, archive
lag, WAL continuity, `pg_wal` growth, free space, and base-backup age.
- PITR restores PostgreSQL only. It does not rewind workspace files. A database
recovery target has no whole-Hub meaning until paired with a workspace
recovery point and reconciliation rule.
## Verification is a ladder, not a checksum
- `pg_verifybackup` validates the backup manifest and system identifier,
detects missing/extra/changed files, checks file checksums, and parses the
required WAL with `pg_waldump`. WAL verification is currently supported only
for plain-format base backups.
([`pg_verifybackup` checks](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
- PostgreSQL explicitly says these checks cannot cover everything a running
server will encounter and still requires test restores plus checks that the
database works and contains correct data.
([`pg_verifybackup` limitation](https://www.postgresql.org/docs/current/app-pgverifybackup.html#APP-PGVERIFYBACKUP))
- `pg_amcheck` can check supported heap/index structures, but currently only
ordinary/TOAST tables, materialized views, sequences, and B-tree indexes are
supported; other relation types are silently skipped. Stronger B-tree checks
can block concurrent modifications.
([`pg_amcheck`](https://www.postgresql.org/docs/current/app-pgamcheck.html#APP-PGAMCHECK))
Derived recovery-evidence ladder:
1. **Artifact verification:** backup command succeeded; manifest and all
required WAL validate; any failure marks the artifact unusable.
2. **Isolated technical restore:** restore to a clean non-production target,
reach the intended LSN/time/timeline, and prove PostgreSQL starts without
exposing it to users.
3. **Database validation:** verify migration history/schema identity, tenant
counts and relationships, critical constraints/sequences, and appropriate
corruption checks.
4. **Workspace validation:** restore the paired snapshot/version, validate file
hashes/ownership/containment, and reconcile database file-change/run state
against actual files.
5. **Application validation:** run read-only tenant-isolation and core workflow
smoke tests with external side effects disabled.
6. **Recovery report:** persist the exact backup, WAL range, workspace point,
release/schema versions, checks, observed RPO/RTO, operator, and failures.
Only a fully passing report may authorize promotion.
## Prisma Migrate production semantics
### What `migrate deploy` proves and does not prove
- In production, `migrate deploy` compares applied migration checksums with the
migration directory, warns when an applied migration was modified, and
applies pending migrations.
([Prisma 6 production workflow](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#production-and-testing-environments))
- It does **not** warn when an already-applied migration is missing, detect
production schema drift, reset the database, generate Prisma Client, or use a
shadow database.
([Prisma 6 production workflow](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#production-and-testing-environments))
- The migration directory is the deployable history and must be committed in
full, including `migration_lock.toml`. Prisma warns against editing or
deleting migrations already applied in production; `migrate deploy` executes
migration SQL rather than deriving a new migration from the Prisma schema.
([Prisma migration histories](https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/migration-histories))
- Prisma uses a database advisory lock for production migration commands so
multiple migration commands do not run simultaneously. The documented lock
timeout is ten seconds and not configurable; after timeout, the caller must
retry. The lock can be disabled by environment variable.
([Prisma 6 advisory locking](https://www.prisma.io/docs/orm/v6/prisma-migrate/workflows/development-and-production#advisory-locking))
Derived Hub implication: `migrate deploy` exit zero proves that this invocation
applied or found no pending migration. It does not prove the live schema equals
the expected migration end state, that the new and previous application builds
are both compatible with it, or that the migration is operationally safe under
production data volume and traffic.
The advisory lock is a migration-command concurrency guard, not a substitute
for the repository's deployment lock or application/schema compatibility. As a
derived safety rule, production preflight should reject
`PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK` unless an explicit reviewed mechanism
provides the same serialization guarantee.
### Transactions and failed migrations
- Prisma's first-party migration guidance states that PostgreSQL migrations
are not wrapped in a transaction by default. Operators may opt in by adding
`BEGIN;`/`COMMIT;` to a migration, but large transactional migrations can hold
locks longer and consume more database resources.
([Prisma transactional behaviour](https://www.prisma.io/blog/prisma-migrate-dx-primitives#what-if-schema-migrations-were-atomic))
- A failed migration records its error in `_prisma_migrations.logs`; further
migrations cannot deploy until the failed state is explicitly recovered.
Recovery is either (a) revert/repair partial steps, mark the migration rolled
back, and redeploy, or (b) manually complete the exact steps and mark it
applied.
([Prisma failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration))
- Prisma error codes `P3009` and `P3018` explicitly report that failed
migrations block new migration application until recovery.
([Prisma error reference](https://www.prisma.io/docs/orm/reference/error-reference#p3009))
- `migrate resolve --rolled-back` changes migration-history state so Prisma may
apply the migration again; it does not itself undo SQL already executed.
`--applied` records success without executing the migration SQL.
([failed-migration recovery](https://www.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing#failed-migration),
[`migrate resolve`](https://www.prisma.io/docs/cli/migrate/resolve))
- Prisma can generate a down SQL script, but its documentation limits the
simple rollback workflow to a **failed** up migration. To reverse a successful
migration, the supported history is a new forward migration generated from a
reverted Prisma schema. A schema down script also cannot restore lost data,
application changes, or manually authored SQL automatically.
([generating down migrations](https://www.prisma.io/docs/orm/prisma-migrate/workflows/generating-down-migrations#considerations-when-generating-down-migrations))
Derived Hub implications:
- Every migration needs a declared atomicity choice. If it is intentionally
non-transactional, the migration must be resumable/idempotent or ship an
exact, tested partial-failure procedure. Blindly rerunning a partially applied
script is unsafe.
- A database backup checkpoint before a destructive or difficult-to-reverse
migration is necessary, but restoring that checkpoint is a whole-database
recovery event—not a substitute for a compatible rollout design.
- Service restart cannot be treated as application rollback after a successful
schema migration. The previous binary may no longer understand the new
schema, and a down migration cannot recreate deleted data.
### Compatibility and rollout ordering
- Prisma documents expand-and-contract as the way to avoid downtime for fields
in active use: add the new representation, deploy code that writes both while
reading the old, migrate and verify data, switch reads, stop old writes, then
remove the old representation.
([Prisma expand-and-contract workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations#example-use-the-expand-and-contract-pattern-to-evolve-the-schema-without-downtime))
- Prisma also warns that generated “renames” may be expressed as add/drop and
lose data unless the SQL is reviewed and customized before application.
([customizing migrations](https://www.prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations#example-rename-a-field))
Derived deployment contract:
1. Migration SQL is immutable after production application and reviewed for
destructive operations, lock duration, data volume, and transaction choice.
2. It is rehearsed against a recent, anonymized production-scale restore and
its runtime/locks/disk growth are recorded.
3. Each release declares a schema compatibility window. Expand releases remain
compatible with both old and new application versions; contract/destructive
releases occur only after old binaries can no longer run.
4. Migration status/drift is checked before changing the live release. A
failed migration blocks deployment and enters an operator runbook; it must
never be hidden by repeated restart attempts.
5. Rollback means switching to a binary proven compatible with the current
schema or deploying a new forward migration. Restoring a backup is the last
resort and must also restore/reconcile the workspace recovery point.
## systemd stop, restart, and dependency semantics
- When `ExecStartPre=`, `ExecStart=`, or `ExecStartPost=` fails or times out,
service startup does not continue to the normal running process. `ExecStop=`
is skipped because startup never succeeded; `ExecStopPost=` is the hook that
still runs after a failed startup.
([systemd start/stop command semantics](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStartPre=),
[`ExecStopPost=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStopPost=),
[permanent upstream man-page source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemd.service.xml#L525-L587))
- `Restart=on-failure` covers non-zero exits and failures/timeouts in service
control processes, including `ExecStartPre=`. An explicit systemd stop does
not trigger automatic restart. Restarts are subject to unit start-rate
limiting.
([`Restart=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Restart=),
[permanent upstream source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemd.service.xml#L856-L995))
- With no `ExecStop=`, systemd sends `KillSignal=` (default `SIGTERM`).
`TimeoutStopSec=` bounds the wait; after it expires, remaining processes can
be forcibly killed with `SIGKILL`/`FinalKillSignal=`.
([`ExecStop=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStop=),
[`TimeoutStopSec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#TimeoutStopSec=))
- Default `KillMode=control-group` applies termination to all remaining
processes in the unit cgroup. systemd explicitly discourages `process` or
`none` because children can escape the service lifecycle and remain running
after the service is considered stopped.
([`KillMode=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html#KillMode=))
- `systemctl stop` waits for the job unless `--no-block` is used, but the command
does not fail merely because an `ExecStop=` command failed: the manager still
forcibly terminates the unit.
([`systemctl stop`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#stop%20PATTERN%E2%80%A6),
[permanent upstream source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemctl.xml#L444-L459))
- `systemctl restart` is a stop followed by a start; `ExecStop=` and
`ExecStopPost=` participate. It does not necessarily flush every unit resource
in the same way as separate completed `stop` then `start` operations.
([restart semantics](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStop=),
[`systemctl restart`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#restart%20PATTERN%E2%80%A6))
- `After=` is only an ordering relationship; it does not pull in another unit.
Requirement and ordering dependencies are independent. During shutdown the
start order is reversed, so a Hub ordered `After=postgresql.service` stops
before PostgreSQL when both are in the shutdown transaction.
([`Requires=` and ordering](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Requires=),
[`Before=`/`After=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Before=))
- `systemctl freeze` only uses the cgroup freezer to suspend process execution;
its documented contract contains no filesystem flush or stable-image
guarantee. It is not a substitute for an application drain or a filesystem
snapshot barrier.
([permanent `systemctl freeze` man-page source](https://github.com/systemd/systemd/blob/924552cfbacd51be0229ac6ce95fe3b69c7b7923/man/systemctl.xml#L653-L675))
Derived assessment of the current unit:
- A Prisma migration failure in the current `ExecStartPre=` prevents the Hub
from starting, then `Restart=on-failure` can retry startup until rate-limited.
That is fail-closed, but it is not controlled migration recovery and can
repeatedly hit a database already left in Prisma's failed state.
- `KillSignal=SIGTERM` plus `TimeoutStopSec=30` is only a deadline. Until the Hub
installs a real termination handler that makes readiness false, stops ingress,
drains/checkpoints active work, closes Feishu and database clients, and reports
success, the unit cannot claim a graceful consistency barrier.
- A backup runbook must issue a controlled drain, verify durable work reached a
reconciled terminal/checkpoint state, stop the Hub, verify the unit and its
cgroup are inactive, and only then snapshot mutable workspace state. A zero
`systemctl stop` exit by itself is insufficient evidence.
- Recovery should not start the normal unit until the restored database and
workspace have been validated. The current unit would run pending migrations
immediately on first start, mutating the restored database before validation
and possibly destroying the ability to inspect the original recovered state.
## Filesystem and rsync boundaries
- PostgreSQL says a plain filesystem copy of a live data directory is unusable:
the database must be shut down, because tools such as `tar` do not create an
atomic snapshot and server buffering remains. A correctly implemented frozen
snapshot can work while PostgreSQL runs, but all volumes containing data,
WAL, and tablespaces must be captured simultaneously; otherwise shut down or
use the continuous-archive base-backup protocol.
([PostgreSQL filesystem backups](https://www.postgresql.org/docs/current/backup-file.html))
- For a PostgreSQL rsync backup, the documented safe pattern is a first pass
while running, followed by shutdown and a second `rsync --checksum`; the
second pass is what makes the result consistent.
([PostgreSQL filesystem backups](https://www.postgresql.org/docs/current/backup-file.html))
- At the filesystem layer, `fsfreeze` is documented to let in-progress
filesystem operations complete, halt new modifications, and flush dirty data,
metadata, and logs before returning. That is still only a filesystem-volume
guarantee; it does not replace PostgreSQL's backup protocol and does not make
separately frozen volumes simultaneous.
([util-linux `fsfreeze` manual source](https://github.com/util-linux/util-linux/blob/master/sys-utils/fsfreeze.8.adoc))
- rsync's `--delete` defaults to deletion during transfer on modern peers, so
the receiver tree changes incrementally. `--delay-updates` merely holds each
updated file and renames files into place rapidly at the end; its own manual
says this only attempts to make updates “a little more atomic” and points to
a separate parallel-tree/switch approach for stronger atomicity.
([rsync `--delete`](https://rsync.samba.org/ftp/rsync/rsync.1.html#opt--delete),
[rsync `--delay-updates`](https://rsync.samba.org/ftp/rsync/rsync.1.html#opt--delay-updates))
Derived Hub implications:
- The current in-place `rsync --delete` deployment is neither an immutable
release switch nor a backup snapshot. A failed/interrupted transfer can leave
a mixed live tree; service stop/restart cannot reconstruct the deleted prior
artifact.
- The workspace snapshot mechanism remains an explicit design choice. Whichever
storage technology is selected must prove atomicity over all workspace
volumes or use a quiesced/no-writer interval, then publish an immutable
version/checksum manifest. Merely copying a changing directory is not enough.
## Derived minimum whole-Hub recovery contract
The primary sources support the following minimum contract; exact RPO, RTO,
retention, storage vendor, and numeric schedules remain product/operations
decisions and must not be invented by implementation.
### Backup path
1. Maintain verified PostgreSQL base backups and a monitored continuous WAL
archive, or obtain and test an explicitly equivalent managed-PostgreSQL
guarantee. Keep logical dumps as a separate portability/selective-restore
layer, not the sole PITR mechanism.
2. Keep PostgreSQL configuration, deployment/release manifests, migration
history, and workspace snapshots in the backup inventory; WAL alone does not
cover them.
3. Create a whole-Hub checkpoint only after ingress is drained and active
workspace writers are stopped/reconciled, unless the eventual storage layer
supplies a proven atomic snapshot/journal spanning the necessary stores.
4. Persist one signed/immutable checkpoint manifest joining PostgreSQL
backup/WAL position, workspace snapshot/version, application revision,
migration digest, tool versions, checksums, timestamps, and validation state.
5. Treat database/WAL/workspace backups as cross-tenant sensitive data. Apply
least-privilege access, encryption, audit, deletion/retention, and an
independently protected integrity manifest; WAL contains effectively all
database changes and PostgreSQL advises protecting it from unauthorized
readers.
([WAL archive confidentiality](https://www.postgresql.org/docs/current/continuous-archiving.html#BACKUP-ARCHIVING-WAL))
### Migration path
1. Serialize deploys and backup checkpoints; run a drift/status gate before
migration, not just `migrate deploy`.
2. Rehearse each migration on a recent production-scale restore and record
runtime, locks, disk/WAL growth, transaction choice, and failure recovery.
3. Require expand/contract compatibility for online changes. A release must
declare which old/new binaries can run against each schema phase.
4. Separate migration orchestration from ordinary service restart so a failed
migration produces one durable incident state and an operator decision—not
an opaque restart loop.
5. Verify a restorable checkpoint before destructive change. Do not describe
backup restore as a normal “rollback”; it is whole-service disaster recovery.
### Restore and promotion path
1. Restore into an isolated environment with real external side effects
disabled.
2. Restore the selected PostgreSQL base backup/WAL target and the checkpoint's
exact workspace version; do not run pending Prisma migrations implicitly.
3. Prove artifact integrity, database startup, recovery target/timeline,
migration/schema identity, tenant invariants, workspace hashes/containment,
and read-only application flows.
4. Record observed recovery point and recovery time. A restore that starts but
misses its target, loses a tenant workspace, has partial migration state, or
fails any invariant is not promotable.
5. Promote explicitly, then enable ingress and external integrations. Preserve
the pre-recovery cluster/WAL and recovery report until the incident is closed.
## Decisions and evidence still required
These values are not fixed by the upstream tools and remain `OPEN` for the SaaS
production contract:
- whole-Hub RPO and RTO, separately for ordinary deletion, database corruption,
host loss, region/provider loss, and migration failure;
- base-backup, logical-dump, workspace-snapshot, WAL, and restore-drill cadence;
- immutable/off-host storage, encryption/key ownership, retention and verified
deletion policy, including org-deletion and legal-retention interactions;
- exact workspace checkpoint/journal technology and database/workspace
reconciliation semantics for in-flight or partially written runs;
- whether restore promotion is whole-platform only or supports a safe
organization/project export-import path (PostgreSQL physical PITR is
cluster-wide);
- migration transaction policy, maximum lock/downtime budget, compatibility
window, and who can authorize contract/destructive phases;
- evidence from at least one production-like full restore and one PITR drill,
including deliberate missing-WAL, corrupted-backup, failed-migration,
interrupted-workspace-write, and restart-during-recovery probes.
@@ -0,0 +1,200 @@
# Clean-host deployment and rollback audit
## Verdict
The current `hub/deploy` path is **not a production deployment mechanism**.
It can update a manually-prepared host in the happy path, but a clean supported
host cannot become a runnable Hub from the documented inputs, and an upgrade
failure can leave the live application tree partially replaced with no
application rollback path.
The accepted initial topology remains viable: one Linux/systemd host,
PostgreSQL, Feishu WebSocket ingress, and a non-root Hub service. The blocker is
the release contract and its verification, not a need to replace that topology.
## Executed evidence
### Static gates
- `bash -n hub/deploy/install_service.sh` and
`bash -n hub/deploy/deploy_platform.sh` pass.
- ShellCheck reports only informational SC2029 findings for intentional
client-side expansion of remote paths. It does not find a shell syntax defect.
- A source-to-installer comparison finds six `requireEnv` settings in
`hub/src/server.ts`, but the generated production environment omits
`HUB_PROJECT_WORKSPACE_ROOT` and `HUB_SESSION_SECRET`.
### Seeded configuration cannot boot the built server
The installer was run against a temporary `BASE`, with the real built Hub as
`HUB_DIR`. It exited successfully after creating `platform.env`. The generated
file was then used as the production configuration while supplying the model and
Feishu values that its message asks the operator to fill. The built server
exited with status 1:
```text
[hub] missing required env: HUB_PROJECT_WORKSPACE_ROOT
```
After that is supplied, `HUB_SESSION_SECRET` is the next required omission.
`HUB_PUBLIC_BASE_URL` is also absent; its code fallback is localhost, which is
not a valid public OAuth callback for a production deployment.
### The service identity is referenced but not provisioned
The installer was executed with `SERVICE_USER=definitely-missing-cph-user` and
mocked `install`/`systemctl` boundaries. It returned success and rendered:
```text
User=definitely-missing-cph-user
WorkingDirectory=.../hub
EnvironmentFile=.../.secrets/platform.env
```
There is no `getent`, `id`, `useradd`, or equivalent validation/provisioning
path. It also creates no service home, cache, state, or project-workspace
directory. This contradicts ADR-0018's implemented deployment invariant that
the Hub runs as a dedicated unprivileged service user. A genuinely clean host
will fail at systemd user resolution; a manually-created user can still fail
later when the agent SDK, `cph` cache, or project workspace needs a writable
path.
### A failed build mutates the live tree before failure
`deploy_platform.sh` was run with mocked SSH and rsync boundaries. The first
remote build was forced to exit 42. The observed order was:
```text
ssh:mkdir -p '/srv/curriculum-project-hub/hub'
rsync:live-tree-mutated
ssh:cd '/srv/curriculum-project-hub/hub' && npm ci && npm run build
```
The deploy command correctly propagated status 42, but it had already applied
`rsync --delete` to the one live directory. There is no staged release,
immutable release identity, current/previous pointer, deployment lock, or
rollback command. `npm ci` also replaces that live directory's dependency tree
before the new build has passed.
## Blocking gaps
### 1. Incomplete clean-host bootstrap
`install_service.sh` writes a unit containing `User=cph-hub` without creating or
validating that account. It does not create owned state/cache/workspace paths,
and its generated environment omits required settings. On its first invocation
it deliberately exits after seeding the environment, after which
`deploy_platform.sh` proceeds to restart a unit that has not yet been installed.
The documented workflow is therefore neither one-shot nor complete.
### 2. The runtime product is not fully delivered
The deploy uploads only `hub/`. The target prerequisite list does not include a
preinstalled `cph`, and no release step builds or installs `crates/cph-cli`.
The Hub starts without checking it: the first agent `cph check` or `cph build`
returns exit 127 while `/api/healthz` remains green. Bubblewrap is asserted by
the unit, but Node/npm, curl, PostgreSQL connectivity, service-user namespace
support, writable storage, and `cph --version` are not checked as one preflight.
### 3. Releases are in-place and have no application rollback
Source, dependencies, build output, and the running service all share one
directory. A build failure, interrupted rsync, or unsuccessful restart can
leave a mixed tree. No release manifest records the git revision, Node version,
dependency lock digest, migration set, or `cph` version that was deployed.
Concurrent deploys are not serialized.
### 4. The deployment health check is only liveness
`/api/healthz` returns a constant success response after the HTTP server starts.
It does not verify database access, migration state, workspace writability,
`cph`, the agent sandbox, or Feishu listener startup. The listener's start
promise is not awaited or reflected in readiness. A release can therefore be
declared successful while its defining workflows are unavailable.
The service unit also claims graceful shutdown, but `server.ts` installs no
`SIGTERM`/`SIGINT` handler and never calls `app.close()` or closes the Feishu
listener. Node's default SIGTERM handling terminates the process; Fastify's
documented graceful path begins only when `fastify.close()` is invoked. The
full data-integrity consequence belongs to the run-lifecycle audit, but the
current deployment contract must not claim draining behavior it does not have.
References:
- [Fastify shutdown hooks](https://fastify.dev/docs/latest/Reference/Hooks/)
- [Node.js signal events](https://nodejs.org/api/process.html#signal-events)
### 5. Application rollback and schema migration are not coordinated
The unit runs `prisma migrate deploy` immediately before every start. Current
migration history includes column drops, index replacement, and data
deduplication; there is no declaration that the previous application remains
compatible after each migration. Switching application code back after a
successful migration is therefore not generally safe.
Prisma documents that `migrate deploy` applies pending migrations but does not
detect schema drift, and a partially failed migration requires explicit
operator recovery. Its recommended zero-downtime direction is an
expand-and-contract sequence. The repository has no pre-deploy migration
status gate, production-data rehearsal, backup checkpoint, expand/contract
rule, or failed-migration runbook.
References:
- [Prisma production migration behavior](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production)
- [Prisma failed-migration recovery](https://docs.prisma.io/docs/orm/prisma-migrate/workflows/patching-and-hotfixing)
- [Prisma expand-and-contract migrations](https://docs.prisma.io/docs/guides/database/data-migration)
### 6. Network binding configuration is misleading
The installer writes `HOST=127.0.0.1`, but `server.ts` ignores it and binds
`0.0.0.0`. An operator following the generated configuration can unintentionally
expose the Hub directly instead of binding it behind the intended TLS reverse
proxy. Public URL validation and proxy topology are not part of deployment
preflight.
## Required release contract
A production-capable implementation on the accepted topology should establish
these invariants:
1. **Provisioned identity and storage.** Installation creates or validates a
non-login `cph-hub` user and explicit persistent state, cache, home, and
workspace paths with tested ownership.
2. **Complete validated configuration.** A preflight validates every required
secret/value, the public HTTPS URL, bind address, PostgreSQL connectivity,
writable storage, `bwrap`, and the exact compatible `cph` binary before the
live service is touched.
3. **Immutable staged releases.** Each revision is built in a new release
directory, identified by revision and manifest. The live service points to a
`current` symlink only after build and preflight pass. Deploys are locked.
4. **Complete product artifact.** The release carries both the Hub and the
matching `cph` executable; readiness checks the binary/version rather than
deferring failure to an agent run.
5. **Real readiness.** Liveness remains cheap, while readiness proves the
dependencies needed to accept work. Deployment success waits for readiness,
not merely an open HTTP port.
6. **Application rollback.** A failed switch returns `current` to the previous
release and verifies readiness. Database changes are never silently reversed.
7. **Migration compatibility.** Every release states whether its migration set
is backward-compatible with the previous application. Future destructive
changes use expand/contract releases; failed migrations follow an explicit
recovery runbook tied to backup evidence.
8. **Observable lifecycle.** Release revision, migration status, readiness
failures, restart/drain outcomes, and rollback results are present in command
output and service logs.
## Verification route
The implementation is not complete until an automated Linux test drives these
scenarios with the real scripts and a production-like PostgreSQL instance:
- first install on a host without the service user or directories;
- incomplete configuration fails before unit installation/restart;
- staged build failure leaves the current release byte-for-byte unchanged;
- successful deploy reports revision and passes readiness;
- failed new release switches back to the previous application;
- missing/wrong `cph`, unwritable workspace, unavailable database, and failed
migration each produce a distinct non-zero deployment failure;
- repeated installation/deployment is idempotent;
- concurrent deployments serialize or one fails clearly.
@@ -0,0 +1,125 @@
# Initial abuse and capacity controls
## Verdict
The initial production boundary requires hard safety controls before customer
traffic. The current project lock and small in-memory trigger queue are useful
local protections, but they do not provide multi-tenant admission, durable
backpressure, storage safety, request isolation, bounded execution, or an
operator stop mechanism.
The accepted contract is recorded in
[ADR-0022](../../../docs/adr/0022-layered-capacity-admission.md) and
[`Spec.System.Capacity`](../../../spec/Spec/System/Capacity.lean). Exact values
remain deliberately open until production-like calibration; the required
dimensions and failure behaviors do not.
## Current implementation inventory
| Surface | Existing protection | Production gap |
| --- | --- | --- |
| HTTP | Fastify's default 1 MiB body limit | No route-class, user, Org, IP, or administrative-write rate limits; request/connection/handler timeouts are unset |
| Project execution | Database lock permits one active run per Project | No platform or Org concurrency ceiling and no fair scheduler |
| Busy Project | Process-local FIFO, default five items and five-minute wait | Restart loses accepted work; expiry is logged and dropped without a durable state or user notification |
| Agent execution | Global max-turn default of 25 | No wall-clock, tool-call, output, memory, CPU, process, or Org-specific budget |
| Feishu messages | Message batching limits text count/characters | File/post attachment count and size are unbounded; downloads may buffer whole files |
| Storage | Workspace path boundary | No Project or Org byte quota, archive-expansion limit, or entity-count ceiling |
| Usage | Provider-reported token/cost fields and Org aggregation | Missing cost can disappear from totals; no soft-threshold notification or provider-mode attribution contract |
| Host | systemd lifecycle | No service CPU, memory, or task ceiling and no audited workload brake |
Most Organization member/team/project lists are also unpaginated, and usage
aggregation can load all matching runs. Those query shapes must be bounded as
part of enforcement rather than hidden behind larger process memory.
## Accepted contract
### Layered limits
- Every mandatory dimension has a versioned, non-overridable platform ceiling.
- An Organization OWNER/ADMIN may configure a lower policy limit.
- Effective limit is `min(platform, organization)`; absent Organization policy
means the platform ceiling, never unlimited.
- Missing or invalid mandatory production ceilings fail startup.
### Admission and fairness
- Project exclusivity remains one active run.
- Platform and Organization concurrency limits apply before starting work.
- Available slots are scheduled fairly across Organizations, FIFO within an
Organization, so one tenant cannot monopolize a global FIFO.
- Accepted work is durable and queryable. Queue length and wait are bounded.
- Overflow is explicit `capacity_exhausted` (`429` plus `Retry-After` for HTTP;
a clear busy response for Feishu). Nothing is silently dropped.
- Wait timeout produces terminal `EXPIRED`, not a later automatic start.
- The initiator may withdraw their queued request. Project `MANAGE` or an
Organization admin may cancel queued requests in their scope. Actor, reason,
and notification are recorded.
### Retry and idempotency
- Feishu `event_id` and API idempotency identify one admission across retries.
- Once a run starts, the whole run is never automatically replayed because tool
side effects may already exist.
- Only bounded transport retries before model output or tool side effects are
automatic. Later failure/timeouts require an explicit new request.
### Request, file, storage, and entity safety
- Authenticated Web requests are limited by user, Organization, and endpoint
class; signed Feishu events by Organization app, chat, and sender;
administrative writes use stricter independent buckets.
- Admin surfaces require both a valid user session and the corresponding
platform/Organization administrator authorization. OAuth callbacks and
signed Feishu ingress remain necessary non-session entry points.
- Hard limits cover request body, single-file bytes, attachment count, archive
expanded bytes/file count/depth, Project/Organization storage, and per-type
Organization entity counts.
- Transfer is streamed and counted; partial files are removed after failure.
- Crossing a ceiling blocks new growth without auto-deleting or hiding existing
resources. Read, export, delete, and administrator remediation stay usable.
### Run budget
Every run is bounded by wall time, model turns, tool-call count, per-tool time,
output/log/event bytes, and process memory/CPU/child-process count. A breached
budget ends explicitly as `TIMED_OUT` or `LIMIT_EXCEEDED`, records the dimension
and observed usage, releases capacity, and never masquerades as success.
### Usage and cost
- Token usage, provider-reported cost, run count, and duration are attributed by
Organization, Project, Run, model, and Provider Connection.
- Missing provider cost is `unknown`, never zero.
- Thresholds are soft alerts in the pilot; they do not automatically stop runs
and are not payment settlement.
- Org admins receive Organization alerts. Platform admins additionally receive
anomalous-cost alerts for platform-managed connections; BYOK does not trigger
platform financial alerts.
### Emergency control
An audited Platform Administrator control supports Organization or global
`DRAIN` (reject new work, pause queue starts, finish active runs) and `STOP_NOW`
(also cancel active runs). Both require an actor, reason, timestamp, explicit
recovery, and user-visible outcomes. This is workload control, not Organization
deletion or account suspension.
## Required production evidence
1. Production configuration validation fails closed for every missing or
invalid platform ceiling.
2. Concurrent noisy-neighbor tests prove platform/Org/Project concurrency and
Organization-fair scheduling without starvation.
3. Restart and crash tests prove accepted queue entries, idempotency, expiry,
cancellation, and notifications survive process loss.
4. HTTP and Feishu load tests prove every rate-limit identity and typed overload
response, including retry guidance.
5. File/archive/storage probes prove streaming enforcement, cleanup, and
continued read/delete access while over limit.
6. Hung provider/tool and resource-exhaustion probes prove every run/process
budget terminates, records cause, and releases locks/capacity.
7. Usage tests prove provider-mode attribution, unknown-cost handling, alert
routing, and absence of hard monetary blocking.
8. `DRAIN` and `STOP_NOW` drills prove scoped, audited, reversible behavior.
9. Production-like calibration publishes the tested numerical defaults and
capacity headroom used by the release gate.
@@ -0,0 +1,191 @@
# Accepted product-surface code inventory
Audit date: 2026-07-10
Scope: source-level inventory for the accepted SaaS product boundary in ADR-0020,
ADR-0021 and ADR-0022. This is not a claim that tests were executed during this
inventory. “Test evidence” below means a repository test exercises the named
path; “no direct test found” means a search of `hub/test/**/*.ts` found no request
to the named route or corresponding service call.
## Contract baseline
The destination is not a shared-bot, single-credential Hub. The repository's
current shared language says:
- an Organization owns its memberships, projects, teams, external connections
and settings (`CONTEXT.md:7-9`);
- a Platform Administrator creates and operates Organizations without becoming
their member, while an Organization Administrator is only an OWNER/ADMIN of
that Organization (`CONTEXT.md:11-17`);
- the Admin Surface is a private web control plane requiring the corresponding
authenticated administrator authority (`CONTEXT.md:19-21`);
- each Organization has its own Feishu application for login, bot messaging and
directory integration (`CONTEXT.md:23-25`);
- provider connections are Organization-scoped BYOK or an Organization-distinct
platform-managed connection (`CONTEXT.md:27-37`);
- capacity uses non-optional platform ceilings, optional lower Organization
limits, durable Organization-fair admission and audited emergency brakes
(`CONTEXT.md:39-77`).
ADR-0021 makes `/admin/platform` and `/admin/org/:orgSlug` separate guarded web
areas (`docs/adr/0021-org-admin-project-onboarding.md:23-40`), requires a
customer-owned Feishu app with encrypted Organization-scoped secrets
(`docs/adr/0021-org-admin-project-onboarding.md:42-47`), and pins both provider
credential modes as Organization-scoped (`docs/adr/0021-org-admin-project-onboarding.md:49-60`).
ADR-0022 makes its safety controls release requirements, not optional future UI
(`docs/adr/0022-layered-capacity-admission.md:22-77`).
Status vocabulary in this inventory:
- **HTTP-backed** / **Feishu-backed**: a production route/event handler is wired.
- **Service/test only**: code can be invoked directly, but there is no production
product entry point.
- **Partial**: part of the journey exists but the accepted end-to-end product
surface cannot complete it.
- **Missing**: no production entry and no suitable persistence/control model.
- **Contract divergence**: reachable behavior actively uses the wrong tenancy or
credential shape.
## Journey matrix
| Target journey | Reachable entry | Authorization | Persistence | Test evidence | Verdict and exact gap |
|---|---|---|---|---|---|
| Platform Administrator signs in | None. The admin plugin registers only Feishu auth and Organization routes (`hub/src/admin/plugin.ts:7-8`, `hub/src/admin/plugin.ts:31-46`). The only HTML is the Org Admin login page (`hub/src/admin/routes/authRoutes.ts:171-207`). | `PlatformRoleAssignment` and `PlatformRole.ADMIN` exist in the schema (`hub/prisma/schema.prisma:111-129`), but the HTTP guard explicitly says the platform control plane is not modeled (`hub/src/admin/auth/guards.ts:1-5`). | Role rows can be stored, but there is no platform session/identity binding, invite/bootstrap flow or platform-admin audit surface. | No platform-auth route test exists. | **Missing.** `/admin/platform` from ADR-0021 is not mounted, so no authenticated operator can reach any platform workflow. |
| Platform Administrator creates/lists/updates/operates Organizations | None. No `/api/platform/*` or Organization CRUD route is registered; the route composition is auth + `/api/org/:orgSlug/*` only (`hub/src/admin/plugin.ts:31-46`, `hub/src/admin/routes/orgRoutes.ts:24-103`). | No `requirePlatformAdmin` implementation; only `requireSession` and `requireOrgRole` exist (`hub/src/admin/auth/guards.ts:43-113`). | `Organization` has slug, name and ACTIVE/SUSPENDED/ARCHIVED status (`hub/prisma/schema.prisma:28-52`). | Organizations are created directly only by test setup, e.g. `prisma.organization.upsert` (`hub/test/integration/helpers.ts:60-77`) and a direct cross-org fixture create (`hub/test/integration/admin-explorer.test.ts:198-210`). | **Service/test only at the database layer.** Manual pilot onboarding has no safe production command, HTTP API or UI; status changes are also unreachable. |
| Platform Administrator configures an Organization's customer-owned Feishu app and checks readiness | None. Runtime accepts exactly one `feishuAppId`/`feishuAppSecret` pair in `AdminPluginConfig` (`hub/src/admin/plugin.ts:10-19`), and server startup reads one process-global app id, secret and bot open id (`hub/src/server.ts:47-52`). | No platform guard or Organization connection ownership check. | There is no Feishu application/secret model. `ExternalDirectoryConnection` stores provider/tenant/source metadata but no app id, bot id or secret reference (`hub/prisma/schema.prisma:196-214`). | No provisioning/readiness test exists. | **Missing + contract divergence.** There is no Organization-scoped encrypted Feishu connection or readiness workflow; production can operate only the one env-configured app. |
| Platform Administrator configures an Organization-distinct platform-managed provider key/base URL | None. No platform provider route or UI is registered. | No platform guard. | The accepted Lean type exists only in the semantic contract (`spec/Spec/System/Organization.lean:52-66`); Prisma has no provider-connection or provider-mode model (`hub/prisma/schema.prisma:26-214`). | Runtime tests prove env values are returned, not Organization values (`hub/test/unit/runtime-settings.test.ts:18-55`). | **Missing + contract divergence.** `EnvRuntimeSettings.provider` ignores its project scope and reads one process-global `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (`hub/src/settings/runtime.ts:20-21`, `hub/src/settings/runtime.ts:46-67`). |
| Platform Administrator applies Organization/platform `DRAIN` or `STOP_NOW` and later resumes | None. | No platform guard, reason capture or scope check. | No workload-brake model exists; only the Lean contract defines `open`/`drain`/`stopNow` semantics (`spec/Spec/System/Capacity.lean:71-87`). | No workload-brake test exists. | **Missing.** The required audited, reason-bearing, reversible emergency control from ADR-0022 (`docs/adr/0022-layered-capacity-admission.md:69-72`) has no product or persistence surface. |
| Organization user logs in with that Organization's Feishu app | `GET /auth/feishu` and callback are HTTP-backed (`hub/src/admin/routes/authRoutes.ts:49-128`). The callback upserts a user and signs a cookie (`hub/src/admin/routes/authRoutes.ts:99-123`). | Login itself is public OAuth; Organization APIs later enforce session + active OWNER/ADMIN membership (`hub/src/admin/auth/guards.ts:43-113`). | `User.feishuOpenId` is one process-global unique identifier (`hub/prisma/schema.prisma:89-109`); the signed cookie carries user id/open id but no Organization or Feishu-connection identity (`hub/src/admin/auth/session.ts:18-23`). | Redirect and mocked callback are covered (`hub/test/integration/admin-auth.test.ts:164-230`); OAuth request construction/exchange is covered (`hub/test/unit/feishu-oauth.test.ts:8-81`). | **HTTP-backed but contract-divergent.** OAuth is built once from the process-global app credentials (`hub/src/admin/routes/authRoutes.ts:38-47`), and the implementation explicitly documents this deferral (`hub/src/admin/auth/feishuOAuth.ts:1-10`). There is no org-selecting login route, connection resolver or issuer/app identity in the user key. |
| Each Organization operates its own Feishu Bot | One WebSocket listener is production-wired (`hub/src/server.ts:76-90`). | Message actions are gated by project grants once a binding resolves (`hub/src/feishu/trigger.ts:715-755`), but there is no initial Organization/connection ownership gate for the inbound app. | Binding stores only `chatId -> projectId`, with no Feishu connection/application id (`hub/prisma/schema.prisma:287-304`). Event receipts store globally unique `eventId`, likewise without connection id (`hub/src/feishu/trigger.ts:687-707`). | Trigger, onboarding and event dedup are integration-tested (`hub/test/integration/trigger.test.ts:211-330`, `hub/test/integration/trigger.test.ts:878-928`). | **Feishu-backed for one bot, contract-divergent for SaaS.** Startup constructs one client and one WS client from one config (`hub/src/feishu/client.ts:854-901`). It cannot operate or route multiple Organization-owned apps. |
| Suspended/archived Organization is prevented from bot work | No platform status-change entry exists. Org HTTP APIs do reject non-ACTIVE Organizations (`hub/src/admin/auth/guards.ts:79-88`). | The Feishu trigger resolves an active chat binding directly and then project permissions; it does not load or check `Project.organization.status` (`hub/src/feishu/trigger.ts:715-755`). | Organization status exists (`hub/prisma/schema.prisma:30-52`). | No suspended-Organization trigger test exists. | **Partial and unsafe.** Even a database-side status change only blocks web admin; an already-bound chat remains triggerable through the bot path. |
| Logged-in Organization OWNER/ADMIN reaches the management panel | OAuth success redirects to `/admin/org/:slug` (`hub/src/admin/routes/authRoutes.ts:237-267`). | All Organization APIs default to OWNER/ADMIN through `requireOrgRole` (`hub/src/admin/auth/guards.ts:66-113`), and tests prove MEMBER is denied while ADMIN succeeds (`hub/test/integration/admin-auth.test.ts:95-131`). | Session is an HMAC cookie; user/membership is re-read for each guard (`hub/src/admin/auth/session.ts:31-57`, `hub/src/admin/auth/guards.ts:43-63`). | Guard behavior is integration-tested (`hub/test/integration/admin-auth.test.ts:56-131`). | **API-backed, UI missing.** The plugin says the static SPA is “later” (`hub/src/admin/plugin.ts:1-3`); no route/static handler serves `/admin/org/:slug`. OAuth therefore redirects to an unmounted page. |
| Organization OWNER/ADMIN manages members and roles | REST endpoints list/add/change/revoke members (`hub/src/admin/routes/membersRoutes.ts:20-98`). | OWNER/ADMIN route guard plus service rules: only OWNER may affect OWNER and the last OWNER cannot be removed (`hub/src/org/members.ts:1-8`, `hub/src/org/members.ts:97-205`). | `OrganizationMembership` persists org, user, role and revocation (`hub/prisma/schema.prisma:54-76`). | Add/list/last-OWNER protection is covered through HTTP (`hub/test/integration/admin-members-teams.test.ts:49-83`). | **HTTP-backed and persisted; management UI missing.** Addition requires a raw `feishuOpenId` and may create the `User` directly (`hub/src/org/members.ts:46-85`); there is no invite/accept or org-connection directory picker. Role-change/revoke variants have less direct HTTP coverage than add/list. |
| Organization OWNER/ADMIN manages teams and team membership | REST endpoints cover list/create/update/archive and membership add/revoke (`hub/src/admin/routes/teamsRoutes.ts:21-139`). | All routes require Organization OWNER/ADMIN; service verifies the team and added user belong to the same Organization (`hub/src/org/teams.ts:177-215`, `hub/src/org/teams.ts:263-275`). | `Team` and `TeamMembership` are Organization/project-principal persistence (`hub/prisma/schema.prisma:143-177`). | Create/add-member/archive is covered through HTTP (`hub/test/integration/admin-members-teams.test.ts:85-160`). | **HTTP-backed and persisted; management UI missing.** No production route exposes `ExternalDirectoryConnection`/external team binding sync, so the Feishu directory portion remains service-only. |
| Organization OWNER/ADMIN creates and manages Folders/Projects | REST endpoints cover explorer, folder create/update/archive, project create/detail/rename/move/archive (`hub/src/admin/routes/explorerRoutes.ts:33-212`). | All routes require Organization OWNER/ADMIN. Service operations scope folder/project ids to the Organization (`hub/src/org/explorer.ts:301-341`); HTTP cross-org lookup is tested. | Folder and Project carry `organizationId`; Folder is transparent and Project owns workspace/session/run relations (`hub/prisma/schema.prisma:238-285`). Project creation also creates a real workspace and default permission rows (`hub/src/projectOnboarding.ts:275-348`). | Create/list and move/archive are HTTP-tested (`hub/test/integration/admin-explorer.test.ts:66-196`); cross-org id access is tested (`hub/test/integration/admin-explorer.test.ts:198-223`). | **HTTP-backed and substantially tested; management UI missing.** This is the strongest accepted product slice. |
| Organization OWNER/ADMIN manages Feishu chat binding | HTTP can archive a binding (`hub/src/admin/routes/explorerRoutes.ts:214-227`). New binding is exposed through the Feishu onboarding card, not web. | Archive requires OWNER/ADMIN at HTTP and then project manager authorization in the shared service (`hub/src/projectOnboarding.ts:231-272`, `hub/src/org/explorer.ts:301-314`). Bind existing requires project MANAGE (`hub/src/projectOnboarding.ts:179-229`). | `ProjectGroupBinding` and the FEISHU_CHAT EDIT grant are transactionally stored (`hub/src/projectOnboarding.ts:190-215`, `hub/prisma/schema.prisma:287-304`). | Bind/one-active/archive/rebind is service-tested (`hub/test/integration/project-onboarding.test.ts:114-160`); card bind is integration-tested (`hub/test/integration/trigger.test.ts:271-330`). No direct HTTP archive test was found. | **Feishu + partial HTTP-backed; UI missing.** Binding identity is not scoped by Feishu application, so the 1:1 uniqueness and lookup are process-global rather than Organization-connection-scoped. |
| Organization OWNER/ADMIN manages project team access | REST list/grant/revoke endpoints exist (`hub/src/admin/routes/accessRoutes.ts:19-79`). | OWNER/ADMIN route guard; service refuses a team from another Organization (`hub/src/permissions/projectTeamAccess.ts:168-195`). | TEAM -> PROJECT `PermissionGrant` persists the role (`hub/prisma/schema.prisma:428-447`). | Grant/list/archive-induced revoke is HTTP-tested (`hub/test/integration/admin-members-teams.test.ts:123-156`). | **HTTP-backed and tested; UI missing.** Only team grants are surfaced. There is no HTTP management of direct USER/chat grants, `PermissionSettings`, or per-role `RoleTriggerGrant`. |
| Organization OWNER/ADMIN manages general project settings, roles/models and agent policies | Only `membersCanCreateProjects` is reachable through GET/PATCH `/settings` (`hub/src/admin/routes/orgRoutes.ts:46-81`). | OWNER/ADMIN route guard. | `OrganizationProjectSettings` stores only that Boolean (`hub/prisma/schema.prisma:78-87`). `PermissionSettings` exists, but no admin route references it (`hub/prisma/schema.prisma:449-467`). Model/role registry is explicitly an in-memory skeleton awaiting DB/admin wiring (`hub/src/agent/models.ts:65-79`). | The Boolean setting is HTTP-tested (`hub/test/integration/admin-auth.test.ts:133-162`); model env behavior is unit-tested (`hub/test/unit/runtime-settings.test.ts:4-64`). | **Partial.** The creation policy works, but the accepted “projects and configuration” control plane lacks editable permission settings, model/role settings and run policies. |
| Organization OWNER/ADMIN chooses BYOK vs platform-managed Provider mode; BYOK admin writes key/base URL | None. | No Organization provider guard because no route/service exists. | No Prisma provider connection, credential mode, encrypted secret handle or rotation metadata. The only persistent `provider` fields are descriptive strings on sessions/runs (`hub/prisma/schema.prisma:308-330`, `hub/prisma/schema.prisma:352-387`). | No provider-mode test. Existing tests assert the global env resolver (`hub/test/unit/runtime-settings.test.ts:18-55`). | **Missing + contract divergence.** Lean/ADR have the mode but production always calls `settings.provider("openrouter", { projectId })` (`hub/src/feishu/trigger.ts:195-199`), whose implementation discards the scope (`hub/src/settings/runtime.ts:46-67`). |
| Organization OWNER/ADMIN views sessions and runs | HTTP list and detail endpoints exist (`hub/src/admin/routes/sessionsRoutes.ts:14-45`). | OWNER/ADMIN route guard; service scopes project/session through Organization id (`hub/src/org/sessions.ts:6-23`, `hub/src/org/sessions.ts:49-87`). | `AgentSession`/`AgentRun` persist provider/model/status/tokens/cost and relations (`hub/prisma/schema.prisma:308-387`). | No direct test for these HTTP routes or `org/sessions.ts` was found. Trigger tests do prove sessions/runs are written, e.g. separate role sessions (`hub/test/integration/trigger.test.ts:850-876`). | **HTTP-backed and persisted, but untested at the product boundary and UI missing.** |
| Organization OWNER/ADMIN views Organization/Project/Folder usage | HTTP Organization and Project usage endpoints exist, with optional folder/date filtering (`hub/src/admin/routes/sessionsRoutes.ts:47-79`). | OWNER/ADMIN route guard. | Rollup derives Organization through Project and uses persisted token/cost facts (`hub/src/org/usage.ts:35-77`, `hub/src/org/usage.ts:106-151`). Missing cost increments `runsWithoutCost`, not known cost (`hub/src/org/usage.ts:106-118`). | No direct usage route/service test was found. `/cost` tests cover the Feishu session command, not these admin endpoints (`hub/test/integration/trigger.test.ts:423-480`). | **HTTP-backed and persisted, but incomplete relative to ADR-0022.** It does not attribute by Provider Connection because none exists, has no alert configuration/delivery, no UI, and no product-boundary tests. |
| Ordinary MEMBER creates a Project from an unbound Feishu group | Mentioning the bot in an unbound chat sends an onboarding card (`hub/src/feishu/trigger.ts:850-887`); card action calls shared project creation (`hub/src/feishu/trigger.ts:554-614`). | User must already exist and have an active Organization membership; ordinary creation is gated by `membersCanCreateProjects` (`hub/src/projectOnboarding.ts:149-177`, `hub/src/projectOnboarding.ts:380-403`). Creator receives USER MANAGE and the chat receives EDIT (`hub/src/projectOnboarding.ts:302-340`). | Project, Folder, workspace, binding, default settings and grants are persisted (`hub/src/projectOnboarding.ts:275-348`). | Card display/create/bind is integration-tested (`hub/test/integration/trigger.test.ts:211-330`); policy denial and grants are service-tested (`hub/test/integration/project-onboarding.test.ts:70-112`). | **Feishu-backed and well-tested for the single-app case.** For a user with multiple active memberships, onboarding refuses and says the org selector does not exist (`hub/src/feishu/trigger.ts:889-929`). With a per-Org app, the inbound connection should supply tenant context; current global bot cannot. |
| Ordinary MEMBER binds an existing manageable Project | Onboarding card lists unbound projects and card action binds one (`hub/src/feishu/trigger.ts:868-885`, `hub/src/feishu/trigger.ts:932-969`). | Non-admin candidate projects require `collaborator.manage`; bind service rechecks project MANAGE (`hub/src/feishu/trigger.ts:951-960`, `hub/src/projectOnboarding.ts:179-215`). | Binding + FEISHU_CHAT EDIT grant persist transactionally. | Card journey is integration-tested (`hub/test/integration/trigger.test.ts:271-330`). | **Feishu-backed for the single-app case; per-Organization connection routing is absent.** |
| Ordinary authorized member triggers an Agent Run from Feishu | Bound message event resolves Project, evaluates `agent.trigger`, resolves role/model/provider, creates session/run and starts the runner (`hub/src/feishu/trigger.ts:684-848`, especially `hub/src/feishu/trigger.ts:715-755` and `hub/src/feishu/trigger.ts:140-256`). | Missing sender id is fail-closed; project EDIT+ and optional role grant are enforced (`hub/src/feishu/trigger.ts:171-194`, `hub/src/feishu/trigger.ts:726-755`). | Session/run, messages, file changes, event receipt and audit fragments persist in Prisma (`hub/prisma/schema.prisma:306-405`, `hub/prisma/schema.prisma:496-525`). | Lifecycle, permission denial, dedup, audit, messages and interrupt have integration coverage (`hub/test/integration/trigger.test.ts:116-154`, `hub/test/integration/trigger.test.ts:597-607`, `hub/test/integration/trigger.test.ts:878-928`, `hub/test/integration/trigger.test.ts:983-1100`). | **Feishu-backed and tested for one process-global provider/Feishu connection.** It is not an Organization-resolved run: provider scope is ignored and bot/identity connection is absent. |
| Accepted work waits under Project/Organization/platform capacity and survives restart | Only a process-local queue per Project exists (`hub/src/feishu/triggerQueue.ts:18-45`). It is drained after the active Project run (`hub/src/feishu/trigger.ts:430-495`). | No Organization admission identity or capacity policy is evaluated. | Queue is a `Map` in memory (`hub/src/feishu/triggerQueue.ts:21-26`), not a Prisma model; accepted requests vanish on process exit. Expired entries are dropped and only logged (`hub/src/feishu/trigger.ts:475-483`). | FIFO/full/expiry behavior is unit-tested (`hub/test/unit/trigger-queue.test.ts:10-78`); locked-trigger enqueue/drain is integration-tested (`hub/test/integration/trigger.test.ts:609-673`). | **Contract divergence.** ADR-0022 requires durable bounded admission, Organization-fair scheduling, explicit EXPIRED/CANCELED states and notifications (`docs/adr/0022-layered-capacity-admission.md:33-46`); none are present. |
| Platform ceiling + optional lower Organization policy protect request/run/file/storage/entity capacity | None beyond Fastify defaults, a five-item Project queue and global max turns. `Fastify({ logger: true })` has no capacity options/plugins (`hub/src/server.ts:55-55`); dependencies include no rate-limit plugin (`hub/package.json:9-17`). | No admin policy route. | No capacity/limit model. `OrganizationProjectSettings` has only `membersCanCreateProjects` (`hub/prisma/schema.prisma:78-87`). Run policy contains only global `maxTurns` (`hub/src/settings/runtime.ts:24-37`, `hub/src/settings/runtime.ts:74-78`). The systemd unit has no CPU/memory/process controls (`hub/deploy/cph-hub.service:6-26`). | Unit tests cover only global max turns and the local queue (`hub/test/unit/runtime-settings.test.ts:58-64`, `hub/test/unit/trigger-queue.test.ts:5-113`). | **Missing.** None of ADR-0022's mandatory platform/org request rate, body, concurrency, file/archive, storage, entity-count, wall-time/tool/output or process limits are product-reachable or persistently enforced (`spec/Spec/System/Capacity.lean:13-60`). |
## Reachability summary
### Production-wired HTTP surface
The actual production plugin mounts these capability groups:
- global Feishu OAuth/session: `/auth/feishu`, callback, logout, `/api/me`, and
the minimal `/admin/login` HTML (`hub/src/admin/routes/authRoutes.ts:49-207`);
- Organization summary and one onboarding Boolean
(`hub/src/admin/routes/orgRoutes.ts:27-81`);
- Folder/Project explorer and binding archive
(`hub/src/admin/routes/explorerRoutes.ts:33-227`);
- member roles (`hub/src/admin/routes/membersRoutes.ts:20-98`);
- teams/team membership (`hub/src/admin/routes/teamsRoutes.ts:21-139`);
- TEAM -> PROJECT grants (`hub/src/admin/routes/accessRoutes.ts:19-79`);
- sessions and usage (`hub/src/admin/routes/sessionsRoutes.ts:14-79`).
Every `/api/org/:orgSlug/*` route uses the same OWNER/ADMIN guard. This satisfies
the clarified requirement that a management API cannot be used by an anonymous
or ordinary Organization member (`hub/src/admin/auth/guards.ts:43-113`). It does
not provide the accepted management **panel**: only `/admin/login` is rendered,
and the post-login `/admin/org/:slug` destination is not served.
### Production-wired Feishu surface
The trigger handler is a real end-to-end application path for one Feishu app:
event receipt/dedup, chat binding, permission checks, unbound-chat onboarding,
Project creation/binding, sessions/runs, streaming and interrupt. The service
wires the handler to a single SDK client and WebSocket listener
(`hub/src/server.ts:76-90`, `hub/src/feishu/client.ts:854-901`). The code and tests
therefore establish a useful single-app vertical slice, not the required
per-Organization connection architecture.
### Only database/service/test reachable
- Organization creation is direct Prisma fixture work
(`hub/test/integration/helpers.ts:60-77`).
- `PlatformRoleAssignment` is a stored type with no platform auth/HTTP surface
(`hub/prisma/schema.prisma:111-129`).
- external-directory sync has service code and can create membership rows
(`hub/src/permissions/externalSync.ts:40-90`), but no registered production
admin route.
- project `PermissionSettings` and `RoleTriggerGrant` are stored/enforced in
parts of the runtime, but have no Organization management API
(`hub/prisma/schema.prisma:449-494`).
### Entirely absent from runtime persistence/control plane
- Organization-scoped Feishu application connection and encrypted secret
handle;
- Organization-scoped Provider Connection, BYOK/platform-managed mode, encrypted
secret handle, rotation and runtime resolver;
- platform administrator session/guard, platform Organization CRUD, platform
admin invitation, platform-operation audit;
- versioned platform capacity ceiling loader/validation;
- Organization policy-limit rows and Organization admin endpoints;
- durable run request/admission state, fair Organization scheduler, queue cancel
and expiry notification;
- Organization/platform workload brake state and audited actions;
- usage/cost alert configuration and delivery;
- the Organization and platform management UI.
## Critical production blockers
1. **No Organization connection control plane.** The running service has one
Feishu app/bot and one provider token/base URL. This contradicts the defining
SaaS requirement even though Project/Team data is otherwise Organization-
scoped (`hub/src/server.ts:44-52`, `hub/src/settings/runtime.ts:46-67`).
2. **No platform control plane.** A Platform Administrator cannot authenticate,
create or operate an Organization, provision its Feishu app, configure its
platform-managed provider connection, or apply a workload brake. The only
apparent Organization creation path is direct database/test code.
3. **No usable management panel.** The backend Organization APIs are meaningful
and guarded, but OAuth redirects to an unmounted `/admin/org/:slug`; platform
UI does not exist at all (`hub/src/admin/plugin.ts:1-8`,
`hub/src/admin/routes/authRoutes.ts:237-267`).
4. **ADR-0022 is almost wholly unimplemented.** The in-memory Project queue is
specifically the implementation divergence ADR-0022 rejects; there are no
Organization-fair durable admission, mandatory layered limits, hard run/file/
storage/process budgets or emergency brakes
(`docs/adr/0022-layered-capacity-admission.md:79-88`).
5. **Tenant context is inferred too late and from global identifiers.** OAuth
creates a globally keyed `User`, bot events bind globally by `chatId`, and an
unbound-chat user with more than one Organization is rejected rather than
routed by the customer-owned Feishu connection (`hub/prisma/schema.prisma:89-109`,
`hub/src/feishu/trigger.ts:715-723`, `hub/src/feishu/trigger.ts:889-929`). This
blocks a correct multi-app implementation and also leaves Organization status
unenforced on the bot path.
## What is already worth retaining
The audit does not imply rewriting the working core. The following seams are
concrete and have useful test evidence:
- Organization ownership on Project/Team/Folder and same-Organization access
checks (`hub/prisma/schema.prisma:143-177`, `hub/prisma/schema.prisma:238-285`);
- OWNER/ADMIN guards and last-OWNER protection
(`hub/src/admin/auth/guards.ts:66-113`, `hub/src/org/members.ts:97-205`);
- reusable service operations for project creation, chat binding and grants
(`hub/src/projectOnboarding.ts:128-229`, `hub/src/projectOnboarding.ts:275-348`);
- Organization-scoped explorer/member/team/team-access HTTP APIs and their
integration tests;
- Feishu onboarding, project permission checks, session/run persistence and
dedup in the single-app vertical slice.
The shortest path to the accepted destination is therefore to add the missing
platform/connection/capacity control planes and real admin UI around these seams,
then change ingress and runtime resolution to carry an explicit Organization
connection identity end to end. Treating the existing env credentials as an
acceptable pilot fallback would preserve the wrong tenant boundary.
@@ -0,0 +1,259 @@
# Accepted SaaS product-surface completeness audit
Audit date: 2026-07-10
## Verdict
The current Hub is **not a usable or production-complete SaaS product surface**
for the accepted destination. It has a worthwhile Organization-scoped backend
foundation and a well-tested single-Feishu-application Agent Run slice, but the
running product still has one process-global Feishu app/bot and one global model
provider credential. It has no platform-administrator control plane, no
Organization connection/secret plane, and no Organization-scoped Provider
Credential Mode. Its only HTML product page is a login form; successful OAuth
redirects an administrator to an unmounted `/admin/org/:orgSlug` route.
ADR-0022's required production safety surface is also almost wholly absent:
there is no durable Organization-fair admission, layered platform/Organization
limits, hard file/storage/run/process budgets, usage-alert control plane, or
audited workload brake. These are release blockers, not optional polish.
The strongest code should be retained rather than rewritten: all current
`/api/org/:orgSlug/*` routes reauthorize an active OWNER/ADMIN membership;
Folder/Project, member, Team, Team-to-Project access, session, and usage services
are Organization-scoped; and the shared project-onboarding services correctly
create Project/chat/grant relationships for the single-app case. The shortest
route is to add explicit Organization connection identity and the two private
admin control planes around those seams.
The claim-by-claim source inventory, including route, schema and test locations,
is in [accepted product-surface code inventory](product-surface-code-inventory.md).
Official Feishu identifier, OAuth, event and long-connection semantics are in
[Feishu product-surface primary sources](product-surface-feishu-primary-sources.md).
## Accepted boundary
The audit treats these as required because they are already accepted by the
repository's `CONTEXT.md`, ADR-0020, ADR-0021, ADR-0022, and Lean contract:
- a Platform Administrator is separate from Organization membership and uses a
private `/admin/platform` area to create and operate Organizations;
- an Organization OWNER/ADMIN uses a private `/admin/org/:orgSlug` area to
manage members, projects, Teams, connections, settings, sessions and usage;
- every Organization authenticates and exchanges bot/directory traffic through
its own customer-owned Feishu Application;
- every Organization chooses BYOK or an Organization-distinct platform-managed
Provider Connection, with different administrators owning the respective
plaintext inputs;
- ordinary members can create/bind a Project from an unbound Feishu group only
under the Organization policy, while all Project actions retain Project-level
authorization;
- production has mandatory platform ceilings, optional lower Organization
limits, durable fair admission, bounded work and audited emergency brakes.
The audit does not add deferred self-service signup, payment collection,
folder ACL inheritance, commercial billing, or a live editor for platform
capacity ceilings. Guided Feishu app setup and manually initiated Organization
creation by an authenticated Platform Administrator remain valid for the pilot.
## Journey result
| Accepted journey | Current product entry | Result | Owning frontier |
| --- | --- | --- | --- |
| Platform Administrator signs in | No platform login/session/guard; only a stored `PlatformRoleAssignment` type | **Missing** | tickets 42, 45 |
| Platform Administrator creates and operates Organizations | Direct Prisma calls in test fixtures only; no `/api/platform/*` or UI | **Test/database only** | ticket 45 |
| Platform Administrator configures each customer Feishu app | One env `FEISHU_APP_ID/SECRET/BOT_OPEN_ID`; no connection or secret model | **Missing and contract-divergent** | tickets 4345 |
| Platform Administrator supplies a distinct platform-managed provider key/base URL | One process-global `ANTHROPIC_AUTH_TOKEN/BASE_URL`; resolver ignores Project scope | **Missing and contract-divergent** | tickets 43, 45 |
| Platform Administrator applies and reverses `DRAIN`/`STOP_NOW` | No persistence, guard, API or UI | **Missing** | tickets 37, 45 |
| Organization user logs in through that Organization's Feishu app | OAuth exists, but is built once from the global app and has no Organization/application issuer | **Single-app only** | tickets 43, 44 |
| Logged-in Organization OWNER/ADMIN opens the management panel | OAuth redirects to `/admin/org/:slug`; no handler/static application serves it | **Stable 404** | ticket 46 |
| Organization OWNER/ADMIN manages members, Teams, Projects/Folders and Team access | Guarded Organization JSON APIs and useful integration coverage exist | **Backend present; browser product absent** | ticket 46 |
| Organization OWNER/ADMIN manages permission/model/role/run policy | Only `membersCanCreateProjects` is exposed; other stored/runtime settings have no control plane | **Partial** | ticket 46 |
| Organization selects Provider Credential Mode and manages BYOK | No mode/connection model, API or UI | **Missing** | tickets 43, 46 |
| Organization views sessions and usage | Guarded JSON APIs exist, but have no direct product-boundary tests or UI and cannot attribute Provider Connection | **Partial** | tickets 36, 46 |
| Ordinary member creates or binds a Project in Feishu | Card/service path is present and well tested | **Works for the one-global-app case** | ticket 44 for SaaS routing |
| Authorized member runs the agent through its Organization bot | Full single-app trigger path exists; provider/app resolution is global | **Works only as a single-app deployment** | tickets 43, 44 plus lifecycle tickets |
| Accepted work queues fairly and durably across Organizations | Five-item process-memory Project queue | **Contract-divergent** | tickets 23, 3235 |
| Organization sets lower policy limits and receives soft alerts | No policy/alert persistence or product surface | **Missing** | tickets 32, 3436, 46 |
## Executed defect evidence
The probes used the real Fastify plugin and test PostgreSQL database through a
temporary Vitest integration file. The file was removed after capture and was
run twice to separate deterministic behavior from a transient test failure.
### 1. The successful-login destination is not served
The probe seeded an active Organization ADMIN, minted the normal signed session
cookie, and requested the exact destination selected by
`resolvePostLoginRedirect`:
```text
GET /admin/org/test-default
expected_status=200
actual_status=404
```
Both runs returned 404. The root cause is structural: `registerAdminPlugin`
mounts cookie, auth and Organization API routes only; its own comment says the
static SPA is “later”. No other repository HTML/TSX/Vue/Svelte/CSS product
application or deployment-level static handler exists. Existing OAuth tests
assert only the redirect `Location`, so they stop immediately before the broken
journey.
This is not an authorization defect in the JSON APIs. Those APIs correctly
return 401 without a session and 403 to a MEMBER. It is a reachability defect:
there is no management panel for either an authorized or unauthorized request
to reach. Ticket 46 must guard both the page shell and its data, so an anonymous
user may reach only the login entry and never management content.
### 2. Different Organizations start OAuth with the same application
The probe seeded two Organizations and requested the current OAuth entry with
two different Organization return paths:
```text
GET /auth/feishu?returnTo=/admin/org/test-default
client_id=cli_process_global
GET /auth/feishu?returnTo=/admin/org/second
client_id=cli_process_global
```
Both runs produced the same result. The root cause is that
`registerAuthRoutes` constructs one immutable OAuth configuration from plugin
startup arguments. The `returnTo` path is signed only as a destination; it does
not resolve or bind an Organization Feishu Application. `server.ts` obtains the
same app credentials from one environment tuple and also uses them for the only
WebSocket listener.
Changing only the redirect page cannot fix this. The composition root and every
identifier-bearing record/action need an explicit Organization connection
identity, backed by the encrypted resolver in ticket 43.
## Feishu identifier and transport consequence
Feishu's official identifier contract makes the current global identifier
shape unsafe for customer-owned applications:
- `open_id` identifies a user inside one application and differs across apps;
- `user_id` is stable inside one tenant but differs across tenants;
- `union_id` can link apps from the same application service provider, but the
service cannot assume independently customer-owned apps share that provider;
- OAuth authorization identifies the app with `client_id`; the callback brings
`code` and `state`, so state and token exchange must remain bound to the
selected Organization/application;
- v2 HTTP event/card headers expose application and tenant identity, while an
encrypted outer request must be routed to the correct connection before its
verification/decryption key is known; a long connection is itself created by
one app credential pair, so the local client/listener identity is the issuer;
- Feishu rejects cross-tenant chat/operator combinations and does not provide a
contract that permits `chat_id` to be treated as a global SaaS key.
The current `User.feishuOpenId @unique`, session `{userId, feishuOpenId}`,
`ProjectGroupBinding.chatId`, `FeishuEventReceipt.eventId`, principal IDs,
sender caches, and batching keys omit this issuer namespace. An `open_id`
collision across customer apps could merge unrelated external identities;
different `open_id` values for one person across apps cannot be resolved as one
global identity automatically. A globally looked-up `chatId` or event ID has
the analogous ambiguity.
The implementation must therefore persist provider-local external identities
under a Feishu Application Connection, for example conceptually
`(connectionId, externalIdType, externalId)`, and carry that connection through
OAuth state/session, ingress, deduplication, chat binding, authorization,
onboarding/card actions, rate-limit keys, directory sync, outbound delivery and
audit. Whether two external identities represent the same global `User` is a
separate explicit linking decision; it must not be inferred from a provider ID
outside its documented scope.
## Root causes rather than symptoms
### 1. Tenant data was added without tenant-scoping external connections
Organization ownership is correctly present on Projects, Teams, Folders and
directory-connection metadata, but the process composition still comes from
the former single-deployment shape. The app/bot/provider credentials and the
identifiers they issue never crossed an Organization connection boundary.
`RuntimeScope.projectId` exists but is discarded by the env resolver, making
the missing seam especially explicit.
### 2. The control-plane work stopped at guarded Organization APIs
The Organization routes provide a credible backend slice, but there is no web
application, platform route family, platform guard, Organization provisioning
service, connection configuration, or browser journey test. The comment
“until admin SPA lands” and redirect-only OAuth assertion document the precise
unfinished handoff. A small catch-all page would only hide the gap; the fix must
deliver the authenticated, authorized workflows and their safe states.
### 3. Production capacity semantics exist only in the contract
ADR-0022 intentionally declares the in-memory queue and global-only settings to
be implementation divergences. There is no common admission identity that can
join Organization, Feishu connection, Project, user and request, so fair
scheduling, workload brakes, rate limits, alerts and control-plane operations
cannot currently compose. The previously created capacity tickets own this
implementation; the admin tickets must expose their safe operator/customer
surfaces rather than invent parallel controls.
## What should remain
- Keep `requireOrgRole`'s per-request session, active Organization, active
membership and OWNER/ADMIN checks; extend the same principle to page data and
add a separate `requirePlatformAdmin` rather than an Organization override.
- Keep the Organization-scoped explorer/member/Team/access/session/usage APIs
as the initial backend for the org panel, while filling their missing tests,
pagination/limits, audit and typed-error contracts through existing tickets.
- Keep shared Project creation and chat-binding services so web and Feishu
entries do not drift into two rule sets.
- Keep the single-app Feishu handler's authorization, onboarding, run/session
and dedup behavior, but call it from a connection-bound listener context and
move accepted work/delivery into the durable lifecycle tickets.
- Keep missing provider cost unknown rather than fabricating zero; add Provider
Connection attribution before presenting SaaS usage claims.
## Newly clear execution frontier
The product audit creates four implementation/decision slices after the
existing secret and browser-boundary design work:
1. [Decide the platform-administrator identity and audit boundary](../issues/42-decide-platform-admin-identity-audit.md).
2. [Implement the Organization connection and secret plane](../issues/43-implement-org-connection-secret-plane.md).
3. [Route Feishu identity and traffic by Organization connection](../issues/44-route-feishu-by-org-connection.md).
4. [Build the private platform-administrator control plane](../issues/45-build-private-platform-admin-control-plane.md).
5. [Build the private Organization-administrator control plane](../issues/46-build-private-org-admin-control-plane.md).
The Feishu routing slice precedes durable inbound/action/outbound and signed
traffic limiting so those mechanisms use the right issuer namespace from their
first durable schema. The platform panel precedes the emergency-brake product
entry so ticket 37 can reuse one authenticated/audited operator boundary.
Existing tickets 3238 continue to own capacity enforcement, attribution,
alerts, brakes and calibration; the new UI tickets do not duplicate them.
## Required production journey evidence
The final release gate must run against at least two configured customer-owned
Feishu applications and two Organizations, using deliberately colliding fake
provider-local IDs where the test harness permits. It must prove:
1. an unauthenticated request and an authenticated wrong-role user cannot load
either management page data or perform mutations;
2. a Platform Administrator can create an Organization, bootstrap its OWNER,
configure/test its Feishu app and distinct platform-managed Provider
Connection, with redacted responses and complete audit history;
3. an Organization OWNER/ADMIN logs in through that Organization's app, reaches
the real org panel, manages the accepted settings/resources, configures BYOK
when selected, and cannot read another Organization or platform secret;
4. each bot receives, binds, authorizes, queues and replies only inside its
connection's Organization even when `open_id`, `chat_id`, event or callback
values collide; SUSPENDED/ARCHIVED Organizations fail closed;
5. provider resolution for every run returns that Organization's selected
connection and never a process-global fallback;
6. current member onboarding, Project authorization and Agent Run behavior
remain intact after connection namespacing;
7. layered limits, fair admission, usage alerts and `DRAIN`/`STOP_NOW` are
visible only to the corresponding authorized control plane and behave as
proven by their dedicated fault/capacity suites;
8. the complete browser + Feishu journey survives the production-shaped deploy,
restart, backup/restore and rollback evidence owned by ticket 08.
@@ -0,0 +1,333 @@
# Feishu multi-app identity and event-routing primary sources
This note answers one narrow design question for the production SaaS audit:
what identity and routing boundaries apply when **every Hub Organization uses
its own Feishu App and bot**. External claims below use only first-party Feishu
Open Platform documentation. Statements labelled **Derived requirement** are
Hub design implications, not guarantees made by Feishu.
Research date: 2026-07-10.
## Executive answer
1. A customer-owned enterprise custom app is a single-tenant app: Feishu says
it is developed, reviewed, published, and used inside one tenant and cannot
be used by another organization. That fits “one Organization, one Feishu
App,” but it means the Hub is operating many independent app security and
identity domains, not one shared Feishu identity domain.
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
2. A bare `open_id` is **not** a SaaS-global user key. It identifies a user
inside one app, and the same user has different Open IDs in different apps.
`user_id` is tenant-scoped; it is stable across apps in one tenant but
differs across tenants. `union_id` crosses apps only for apps from the same
application provider/developer; it differs between providers.
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN),
[official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN),
[Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
3. OAuth must be routed to the Organization's app **before** authorization.
The authorization request contains that app's App ID as `client_id`; the
callback contains `code` and the echoed `state`, not an independently
trustworthy Organization choice. Exchanging the code requires the matching
app's `client_id` and `client_secret`, and the same `redirect_uri`.
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
[obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
4. A version 2 event or callback carries `header.app_id` and
`header.tenant_key` after parsing/decryption. An encrypted HTTP delivery,
however, exposes only an `encrypt` envelope before decryption, so a shared
endpoint cannot safely choose among many apps' Encrypt Keys from the body.
([callback structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview),
[encrypted Webhook delivery](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
5. A WebSocket/long-connection client is authenticated with one app's App ID
and App Secret. Therefore its local client instance is the pre-authenticated
source-app route. It is not a process-global “all customer apps” listener.
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
6. Feishu documents `chat_id` as the conversation identifier, but the cited
documentation does not promise that a raw `chat_id` is globally unique
across every tenant and app. Chat APIs explicitly reject an operator and
chat from different tenants and often require the app bot to be in the
chat. A bot is represented by its application's `app_id`.
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
[get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN),
[message resource](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
## 1. Application and tenant boundary
### Official facts
- `app_id` is the immutable identifier generated by Feishu for an application.
`tenant_key` is the enterprise identifier; one `tenant_key` corresponds to
one enterprise in actual use.
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology))
- An enterprise custom app is restricted to one tenant. Its developers,
administrators, and users belong to that tenant, and another organization
cannot use it. A store app is the contrasting model that can be installed by
multiple tenants.
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
- Access tokens tell Feishu which app/user is calling and which tenant's data
is being accessed. A `tenant_access_token` calls as the application and its
data range is determined by that application's permissions.
([access-token overview](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
- A custom app obtains its `tenant_access_token` using that app's `app_id` and
`app_secret`.
([custom-app tenant access token](https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN))
### Derived requirements
- Model a durable `FeishuAppConnection` (or equivalent) owned by exactly one
Hub Organization. At minimum it binds the internal connection ID,
`organization_id`, `app_id`, expected `tenant_key`, encrypted `app_secret`,
event/callback credentials, lifecycle state, and credential version.
- Do not select Feishu credentials from a process-global default. Every OAuth
exchange, token cache entry, OpenAPI call, Webhook verification, and
long-connection client must start from the Organization's connection.
- Pin the first verified `tenant_key` to the connection. Later login, event,
and callback data must match both the expected `app_id` and pinned
`tenant_key`; unknown or mismatched pairs must be rejected rather than
auto-attached to another Organization.
## 2. User identifier scopes
### Official scope matrix
| Identifier | Feishu's documented scope | Cross-app/tenant behavior | Availability caveat |
| --- | --- | --- | --- |
| `open_id` | User identity inside one application | The same user has a different `open_id` in different apps | The normal app-relative user identifier |
| `user_id` | User identity inside one tenant | Stable across all apps in the same tenant; different in tenant A and tenant B | Reading it is a sensitive field permission and is limited to custom apps in the cited user API |
| `union_id` | User identity across apps from the same application provider/developer | Same across that provider's apps; different across different providers | It does not make unrelated customer-owned apps one identity domain |
| `tenant_key` | Enterprise identity | One key corresponds to one enterprise | Returned by login and carried in events/callbacks |
Sources:
- Feishu's current user resource overview states that `user_id` is tenant
identity, `open_id` is app identity, and `union_id` is identity across apps
developed by the same application provider. It expressly says the same
user's Open ID differs between apps.
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN))
- Feishu's API parameter text is more explicit: the same user's User ID differs
between tenants but remains the same across apps within one tenant; the same
user's Union ID is the same under one developer and different under another.
([official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN))
- Feishu describes Union ID as the identity linking one user across multiple
applications supplied by the same application service provider.
([Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
- Feishu's common-error guidance says not to use an Open ID obtained from app A
in app B, and even a test app and its production app produce different Open
IDs for the same user. It also reports cross-tenant errors for both User ID
and Union ID use.
([common errors](https://open.feishu.cn/document/ukTMukTMukTM/ugjM14COyUjL4ITN?op_tracking=hc))
- The current login user-info API returns `open_id`, `union_id`, optional
sensitive `user_id`, and `tenant_key` from a `user_access_token`.
([get login user information](https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get?lang=zh-CN))
### Answer: can different customer Apps share one global `open_id` key?
**No.** This directly contradicts Feishu's documented Open ID semantics. Two
customer-owned custom apps are different applications, so the same person has
different Open IDs in them. Because those apps are independently owned custom
apps, the Hub also has no documented basis to assume they are applications of
one application provider and therefore no basis to auto-merge on `union_id`.
`user_id` cannot solve cross-customer identity either because it changes across
tenants.
Derived identity model:
- The safe external login key is `(feishu_app_connection_id, open_id)`; retain
`tenant_key`, `user_id`, and `union_id` as scoped attributes, not as an
unqualified SaaS-global primary key.
- If the product needs one Hub human account to participate in multiple
Organizations, represent each Feishu login as a separate external identity
attached through an explicit, separately verified account-linking flow. Do
not silently merge on Open ID, Union ID, email, phone, or display name.
- Do not make `user_id` mandatory for login unless every customer app is
required to obtain its sensitive field permission. `open_id` and
`tenant_key` are returned by the user-info flow without using `user_id` as
the login key.
- Replacing an Organization's Feishu App changes the application identity
domain and therefore its users' Open IDs. Treat app replacement as an
explicit connection migration/re-link operation, not an in-place secret
edit.
## 3. OAuth login must be app-routed
### Official facts
- The authorization URL requires `client_id`, which is the selected
application's App ID. `redirect_uri` must already appear in that
application's security settings. On success Feishu redirects with `code`
and, when supplied, the original `state`.
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code))
- Feishu says `state` maintains request/callback context and must be compared
to prevent CSRF. Its official sample rejects a callback whose state differs
from the stored state.
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
[user-access-token sample](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
- The code lasts five minutes and can be used once. Token exchange requires
`client_id`, `client_secret`, `code`, and, when used during authorization,
the same `redirect_uri`. Feishu defines error 20024 for a code/refresh token
that does not match the supplied client ID and error 20071 for a mismatched
redirect URI.
([obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
- Only redirect URLs configured for the selected application pass Feishu's
security check; an app may configure multiple URLs.
([access-token acquisition flow](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
### Derived request flow
1. Begin at an Organization-qualified route or invitation, resolve exactly one
active `FeishuAppConnection`, and create a short-lived, one-time server-side
login transaction bound to that Organization, connection, redirect URI,
desired return path, and PKCE verifier if used.
2. Put only a high-entropy opaque transaction nonce in OAuth `state`; generate
the authorization URL with that connection's `app_id` as `client_id`.
3. On callback, atomically consume and validate `state` before selecting
credentials. The callback's untrusted query parameters must never be
allowed to choose another Organization or App.
4. Exchange `code` with the connection's `app_id`, decrypted `app_secret`, and
the exact stored redirect URI. Then call user-info and require its
`tenant_key` to equal the connection's pinned tenant.
5. Resolve or create the membership through
`(feishu_app_connection_id, open_id)`. Never search all Organizations for a
bare Open ID and accept the first match.
A single physical callback URL may be configured independently in multiple
apps, but a shared URL does not remove the need for state-bound app routing.
## 4. HTTP event and callback routing
### Official facts
- A version 2 callback header contains the application's Verification Token,
`event_type`, `tenant_key` (the callback application's tenant), and `app_id`
(the callback application's App ID).
([callback overview and structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview))
- Version 2 event examples likewise place `app_id` and `tenant_key` in the
event header.
([user-leaves-chat event](https://open.feishu.cn/document/server-docs/group/chat-member/event/deleted-2))
- Verification Token is an application validation identifier. Feishu says the
server can compare the event's token with that application's token to check
that a request is for the specified app. With an Encrypt Key, the request
signature is calculated from request timestamp, nonce, that app's Encrypt
Key, and the original request body.
([receive and verify events](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case))
- When an Encrypt Key is configured, the HTTP request body visible before
decryption is an envelope containing only `{"encrypt":"..."}`; `app_id`
and `tenant_key` are inside the ciphertext.
([send events to developer server](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
### Derived routing and verification requirements
- Give every Feishu connection a stable, unguessable callback route such as a
connection-specific path token. Resolve the connection from that route,
load exactly its Encrypt Key/Verification Token, verify and decrypt, then
require the authenticated payload's `app_id` and `tenant_key` to match the
route's connection.
- For an unencrypted common endpoint, `header.app_id` may identify a candidate
connection, but it is untrusted until that connection's signature/token has
been verified. Never route the side effect solely from an unverified body.
- A single encrypted URL shared by every customer app has no documented
pre-decryption app selector. Trying every tenant's secret is not an identity
protocol. Use connection-specific routing instead.
- Namespace delivery deduplication by the authenticated connection (for
example `(connection_id, event_id)`), and dispatch business work only after
app/tenant verification. Unknown, disabled, or mismatched connections fail
closed and produce an auditable security log.
## 5. Long-connection routing
### Official facts
- Feishu's SDK creates a WebSocket client with one app's required `APP_ID` and
`APP_SECRET`. Events subscribed by that application are then delivered over
that channel. Authentication is performed when the connection is
established.
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
- Long-connection event subscription supports enterprise custom apps, not
store apps. Each app may establish at most 50 connections. If one app has
multiple clients, delivery is cluster-mode rather than broadcast: one random
client receives a message.
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
- Long-connection callbacks use the same per-app App ID/App Secret client
construction and have the same cluster-mode single-recipient behavior.
([long-connection callback subscription](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/step-1-choose-a-subscription-mode/configure-callback-request-address?lang=zh-CN))
### Derived routing requirements
- Create one logical client binding per enabled `FeishuAppConnection`, even if
an in-process supervisor manages many clients. Attach immutable
`organization_id` and `connection_id` context to each handler; the client
that received the frame is the pre-authenticated application route.
- Never infer the receiving Organization by looking up a bare actor Open ID or
chat ID. First use the authenticated client binding; then cross-check any
delivered `app_id`/`tenant_key` fields available in the event structure.
- Scaling several replicas for the same app does not create duplicate
broadcast processing according to Feishu's contract, but delivery still
requires application-level idempotency and health ownership. Capacity must
count clients per app against the documented 50-connection ceiling.
## 6. Bot, chat, and message identifiers
### Official facts
- In the message resource, a sender can be a user or an application. For an
application sender, `id_type` is `app_id`; the sender also carries a
`tenant_key`. Thus the Feishu bot identity used by messaging is its
application identity, not a Hub-global bot ID.
([message management overview](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
- `chat_id` identifies a conversation, including direct and group chats.
Feishu's group resource also exposes the chat's `tenant_key`.
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
[group management overview](https://open.feishu.cn/document/server-docs/group/chat/intro?lang=zh-CN))
- Chat APIs require the operator to be in the same tenant as an internal chat;
common errors include `Operator and chat can NOT be in different tenants`.
Many operations additionally require the calling app's bot to be in the
group and reject an app that is unavailable in that tenant.
([get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN))
- The first bot direct-chat event calls `chat_id` the conversation ID between
**that robot and user** and includes `app_id`, `tenant_key`, and app-scoped
user Open IDs in the same event.
([user and bot first conversation](https://open.feishu.cn/document/server-docs/group/chat-member/event/bot-events?lang=zh-CN))
### What the official sources do not establish
The cited documentation calls `chat_id` a conversation/group identifier but
does not state that its value is globally unique across every Feishu tenant and
every independently created app. It does establish tenant and bot-membership
authorization boundaries. Under the repository rule that unspecified scope
must not be assumed, raw global uniqueness would be an unsupported inference.
Derived storage/call rules:
- Store chats as `(feishu_app_connection_id, chat_id)` and retain observed
`tenant_key` and chat type. Do the same for other resource identifiers unless
Feishu explicitly contracts a wider scope.
- Always call chat/message APIs with the token obtained from the chat's bound
connection. Never retry an authorization failure with another Organization's
token.
- Treat bot direct chats as connection-specific. A `chat_id` for user X's
conversation with Organization A's bot is not evidence of a conversation
with Organization B's bot.
- For group chats, two customer apps may both be members of a real Feishu
group, but that does not authorize the Hub to move configuration, Projects,
or memberships between Organizations. The authenticated connection remains
the tenant route and Hub Organization remains the authorization root.
## Minimum invariants for implementation review
1. `FeishuIdentity` uniqueness is `(connection_id, open_id)`, never bare
`open_id`.
2. OAuth `state` resolves one server-side transaction bound to one Organization
and connection; code exchange uses only that connection's credentials and
stored redirect URI.
3. Login accepts the returned identity only when `tenant_key` matches the
connection's pinned tenant.
4. HTTP Webhooks choose a connection-specific verification/decryption context
before trusting payload routing fields; authenticated `app_id` and
`tenant_key` must match it.
5. Every long-connection handler carries the local connection/Organization
binding of the App ID/App Secret client that received the frame.
6. Bot, chat, event-deduplication, token-cache, and user identifiers are all
namespaced by the Feishu connection unless Feishu explicitly guarantees a
wider scope.
7. A credential, app, or tenant mismatch is a hard failure with traceable
logging; there is no fallback to a default app or search across tenant
secrets.
@@ -0,0 +1,245 @@
# Production observability and incident-recovery audit
Date: 2026-07-10
Scope: the `hub` HTTP service, Feishu WebSocket ingress and outbound calls, PostgreSQL/Prisma, agent execution, the `cph` subprocess, project workspaces, and the packaged deployment path. This is a release-gate audit, not a proposal to change product semantics. Where the Lean contract is open, this report asks for an operational decision rather than inventing one.
## Verdict
**No-go for unattended SaaS production deployment.** The service exposes a liveness-shaped endpoint, emits some structured component logs, and persists useful run/audit fields, but it cannot currently answer the minimum production questions:
1. Can this instance accept a real request *now* (database reachable, Feishu listener authenticated, required runtime executable and workspace usable)?
2. Which tenant, request, Feishu event, run, subprocess, and outbound message participated in one failed user action?
3. How many requests/runs/messages are failing, stuck, delayed, retried, or duplicated?
4. Which condition should page an operator, and which documented recovery action is safe?
5. After a restart, deploy, rollback, or dependency outage, what evidence proves that work was neither lost nor silently acknowledged?
Three fault injections demonstrate that the current `/api/healthz` and logging surface can be green while the service is already unable to serve its core paths:
- After startup, cutting PostgreSQL sockets left two consecutive `/api/healthz` requests at HTTP 200.
- An invalid Feishu app ID was rejected by the installed Feishu SDK while `/api/healthz` remained HTTP 200.
- With PostgreSQL cut, `/api/me` returned HTTP 400 `bad_request`, exposed a raw Prisma invocation in the response, produced no request-scoped application error log, and only emitted two unscoped Prisma error lines.
Together with the already-confirmed lifecycle and deployment failures, this means green process state, green health checks, and even a visible user error do not prove that the service or its durable workflows are healthy.
## Evidence and positive signals already present
The starting point is not empty. These are useful foundations to retain:
- Fastify's built-in HTTP logging records request/response facts with an internal request ID through the server logger ([`hub/src/server.ts`](../../../hub/src/server.ts)).
- The same Fastify logger is threaded into the Feishu trigger/runtime, while Prisma emits its configured error/warn events (and optional query events) ([`hub/src/server.ts`](../../../hub/src/server.ts), [`hub/src/db.ts`](../../../hub/src/db.ts)).
- `AgentRun` persists status, timestamps, error/result fields, token/cost fields, and links to project/session; `ProjectAgentLock` persists a heartbeat; `AuditEntry` and `AgentMessage` provide potential durable trace anchors ([`hub/prisma/schema.prisma`](../../../hub/prisma/schema.prisma)).
- Agent execution emits intermediate activity to the streaming Feishu card, and lock heartbeat activity is attempted during execution ([`hub/src/agent/runner.ts`](../../../hub/src/agent/runner.ts), [`hub/src/feishu/trigger.ts`](../../../hub/src/feishu/trigger.ts)).
- Deployment is managed by systemd, so restart policy, exit status, and journal collection have an existing operational host surface ([`hub/deploy/cph-hub.service`](../../../hub/deploy/cph-hub.service)).
- Database migrations are explicit Prisma artifacts rather than ad-hoc startup DDL ([`hub/prisma/migrations`](../../../hub/prisma/migrations)).
- Existing tests cover many individual authorization, trigger, lock, queue, runner, and API behaviors. They are valuable regression evidence, although they are not yet a deploy/recovery acceptance suite.
These signals are fragmented rather than joined into an operational contract. Several writes are best-effort or swallowed, identifiers are not propagated end-to-end, there is no metrics/alert surface, and the health endpoint does not test dependencies.
## Failure-to-signal matrix
| Surface | Failure or degraded condition | Current externally visible signal | What is missing / why the signal is unsafe | Minimum production signal and recovery evidence |
| --- | --- | --- | --- | --- |
| HTTP | PostgreSQL becomes unreachable after startup | Affected request fails; `/api/healthz` stays 200 | Health is process liveness only. `/api/me` misclassifies the dependency failure as client `400 bad_request`, leaks raw Prisma call text, and creates no request-scoped error record. An LB/operator cannot distinguish bad input from platform outage. | Separate liveness and readiness. Readiness performs a bounded DB probe and returns non-2xx on failure. Unexpected exceptions map to sanitized 5xx with a stable error code and `request_id`; structured log contains the same ID and normalized Prisma category. Alert on readiness failure and 5xx/error-code rate. |
| HTTP | Handler latency, event-loop stall, exhausted connections, overload | Access log only after a response is completed | No in-flight count, latency histogram, timeout counter, saturation signal, or request correlation. A hanging request may emit no timely evidence. | Request count, duration, in-flight requests, timeout/cancellation count, response class, and event-loop/runtime/process saturation. Bound request duration and log start/end/error with one `request_id`. |
| Feishu ingress | Invalid/revoked app credentials; WebSocket connect/auth failure | SDK rejects/logs asynchronously; process and `/api/healthz` remain green | Startup success is reported before listener readiness. No listener state, last-connected/last-event timestamp, reconnect counter, or readiness dependency. The installed SDK already exposes `onReady`, `onError`, `onReconnecting`, `onReconnected`, `getConnectionStatus`, and `close`, but the Hub does not retain or wire the `WSClient`. systemd can perpetually supervise a process that cannot receive work. | Listener state machine (`starting/connected/degraded/stopped`), last successful connection/event, reconnect/auth-failure counters, bounded startup readiness gate, and alert on auth failure or prolonged disconnect. Credential contents must never be logged. |
| Feishu ingress | Duplicate delivery, handler failure after receipt, process restart with queued/batched work | Event receipt/dedup row and selected warning logs | The run-lifecycle audit proved receipt-before-success and in-memory batching/queue loss. There is no durable processing state/lease, retry age, dead-letter view, or alert for accepted-but-unstarted work. | Durable ingress states and timestamps, attempts, last normalized error, event/chat/project correlation, queue depth/oldest age, dead-letter count. Recovery drill proves restart resumes or deliberately redrives exactly the intended work. See [run lifecycle audit](./run-lifecycle-integrity-audit.md). |
| Feishu outbound | API timeout/ambiguous success, rate limit, invalid credentials, permanent card patch/send failure | Some helpers log a warning; several message helpers catch failure and return `null` | No idempotent delivery record, delivery state, attempt count, provider request/message ID, retry delay, terminal failure metric, or user-visible reconciliation path. Fault injection proved a run can be marked completed although nothing was delivered. | Durable outbox/delivery state; idempotency key; provider message/request ID; attempts, latency and normalized provider error; queue depth/oldest age/dead-letter signals; recovery command to retry or reconcile safely. |
| PostgreSQL | Startup unavailable | Process startup rejects | This is a useful fail-fast signal, but it is not joined to a deploy gate with bounded logs and rollback criteria. | Installer/supervisor waits for explicit readiness, captures failure reason, and aborts/rolls back without replacing last-known-good service. |
| PostgreSQL | Runtime outage, pool saturation, slow queries, migration incompatibility | Raw Prisma errors; request symptoms | No readiness failure, pool/saturation metric, query latency/timeout signal, migration compatibility assertion, or tenant/request/run correlation. Raw client internals can cross the HTTP boundary. | DB probe latency/result, Prisma error code/category counters, bounded query timing for critical operations, connection/pool saturation if available, schema migration identifier, sanitized API mapping, and DB outage/runbook drill. Prisma documents structured client error types/codes and configurable event-based logging; use those instead of returning raw exception strings ([Prisma error reference](https://www.prisma.io/docs/orm/reference/error-reference), [Prisma logging](https://www.prisma.io/docs/orm/prisma-client/observability-and-logging/logging)). |
| Agent run | SDK/model error, credentials invalid, provider throttling, malformed stream | Run may become `FAILED`; the card may show an error and trigger finalization may warn | The installed SDK emits API retry, rate-limit, tool-progress, runtime-init/MCP-status, provider request ID, duration, and error-category messages, but `runner.ts` handles only stream/assistant/user/result messages and drops the other lifecycle signals. Provider/model/attempt/latency/normalized error are not consistently available as metrics or correlated logs. Some persistence and audit operations are best-effort. | Run start/finish transition event with `run_id`, `session_id`, tenant/project, provider/model, status, duration, turns, tokens/cost, normalized error and retryability; capture the SDK's bounded retry/rate-limit/tool/runtime signals; aggregate run outcome/latency/stuck gauges without high-cardinality IDs as metric labels. |
| Agent run | No result/hang, missed heartbeats, process death, terminal DB write failure | Lock heartbeat may stop; restart sweep mutates some state | No wall-clock deadline/watchdog, stale-lock/stale-active-run metric, readiness impact, page, or documented reconciliation. Lifecycle fault injections proved active run without lock, terminal run with lock, and timeout misclassification are possible. | Deadline and durable lease semantics, gauges for active/waiting/stale runs and locks, oldest age, transition-failure counter, reconciliation command and drill. Startup recovery must be transactional and evidence-producing. See [run lifecycle audit](./run-lifecycle-integrity-audit.md). |
| `cph` subprocess | Missing binary, permission failure, timeout, non-zero exit, malformed output | In production, `cph_check`/`cph_build` grant the Claude SDK's Bash tool; tool results are forwarded only to the in-memory streaming card. The direct `agent/cph.ts` wrapper captures exit/stdout/stderr but is used only by its integration tests, not by the production runner. | There is no production-owned `cph` invocation boundary, startup/readiness validation of the exact deployed artifact, version/build identity, persisted operation result, duration metric, bounded/redacted stderr contract, or reliable distinction between spawn failure and domain diagnostics. | Readiness checks executable path/version and required filesystem permissions without mutating domain state. Introduce an observable production invocation boundary or capture the SDK Bash/tool lifecycle with operation, run/project correlation, duration, exit category and bounded redacted diagnostic; metrics count timeout/spawn/non-zero/protocol errors. Deployment evidence pins and smoke-tests the artifact. |
| Workspace | Missing/unwritable root, wrong ownership, disk/inode exhaustion, symlink/root escape, partial agent writes | Error appears only when a command/file operation happens | No readiness check, free-space/inode monitoring, workspace mutation journal/commit marker, or recovery procedure. Security audit already proved containment failures; lifecycle audit proved a run can fail after workspace side effects. | Bounded non-destructive readiness check for configured root, owner/mode, read/write/rename/fsync as required, free space and inode signals, per-project mutation correlation, containment validation, backup/restore and partial-write reconciliation drill. See [tenant/auth security audit](./tenant-auth-security-audit.md) and [run lifecycle audit](./run-lifecycle-integrity-audit.md). |
| Deploy | Bad env/service user/artifact, failed migration/build, unhealthy new process | Shell/systemd exit in some cases; process may restart | Clean-host audit found missing required env/service identity/artifact and in-place, non-atomic release replacement. Health is liveness-only and there is no last-known-good rollback unit. | Immutable versioned release, preflight, migration compatibility gate, explicit readiness deadline, release/build/schema identity endpoint/log, automated rollback criteria, last-known-good artifact and tested rollback. See [clean-host deployment audit](./clean-host-deployment-audit.md). |
| Deploy | SIGTERM/restart during active work | systemd stops/restarts the process | No graceful drain/stop readiness, bounded shutdown, durable queue resume, or post-restart reconciliation proof. In-memory queues are lost. | On termination: readiness false, stop ingress, drain or checkpoint durable work, enforce timeout, emit shutdown summary. Recovery drill sends work across restart and verifies a single durable outcome and delivery. systemd supports explicit notification/readiness/watchdog protocols, but the current unit does not implement one ([systemd service types](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html), [`sd_notify`](https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html)). |
## Logs and end-to-end traceability
### Current gaps
- Fastify generates an internal request ID for its access logs, but it is not returned to the client as a support handle or propagated into application errors, Prisma diagnostics, Feishu work, runs, or subprocess calls.
- Feishu `event_id`, provider request/message IDs, `chat_id`, `project_id`, `organization_id`, `run_id`, `session_id`, and subprocess invocation are not propagated as one causally connected context.
- The DB-cut experiment produced raw Prisma diagnostics without an application error record that identified route, request, response class, or tenant context.
- Several critical failure paths catch and ignore persistence errors or only warn, including audit/message persistence and some card/lock finalization paths. This creates absence of evidence precisely when durable state is least trustworthy.
- The production runner discards SDK `api_retry`, `rate_limit_event`, `tool_progress`, runtime initialization/MCP status, and related system messages even though the installed SDK exposes the fields needed to diagnose provider and tool degradation.
- Exception strings can cross the API boundary. Besides leaking implementation details, this makes the client-visible error vocabulary unstable and impossible to alert on reliably.
- Logs have no documented retention, rotation, ingestion, access-control, redaction, or tenant-support lookup contract. Journald availability alone is not a log strategy.
### Minimum structured event envelope
Every boundary and durable state transition should emit one structured event with a stable `event_name`, `timestamp`, `severity`, `service`, `environment`, `release`, and the applicable correlation fields:
`request_id`, `feishu_event_id`, `organization_id`, `project_id`, `chat_id`, `actor_user_id`, `run_id`, `session_id`, `delivery_id`, `provider_request_id`, and `cph_invocation_id`.
Raw prompt/message/file contents, credentials, cookies, authorization headers, provider tokens, full Prisma invocation text, and unrestricted stdout/stderr must not be logged. Tenant/project/user identifiers are operationally necessary but access-controlled personal/customer metadata; metric labels must not use high-cardinality IDs. OpenTelemetry's HTTP semantic conventions specify the stable `http.server.request.duration` instrument, an opt-in `http.server.active_requests` instrument, and low-cardinality route/error guidance ([HTTP metrics semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/)).
At minimum, events are required for:
- request accepted/completed/failed/canceled;
- Feishu listener connected/disconnected/auth-failed/reconnecting;
- ingress received/deduplicated/queued/claimed/started/retried/dead-lettered;
- run and lock state transitions, transition-write failures, heartbeat stale detection, and recovery decisions;
- provider/agent start/result/error with normalized category;
- `cph` spawn/result/timeout/protocol failure;
- workspace preflight and mutation/commit/reconciliation decisions;
- outbound delivery queued/attempted/confirmed/retried/dead-lettered;
- deploy, schema migration, readiness transition, drain, shutdown, startup recovery, and rollback.
Errors must be allowed to fail the affected operation rather than disappear. If a non-critical telemetry sink is unavailable, that telemetry failure itself needs a bounded local signal/counter; durable business audit records are not optional telemetry and must follow their explicit transactional contract.
## Health, readiness, and startup contract
The service needs three distinct concepts:
1. **Liveness**: the process/event loop is responsive. This endpoint must be cheap and must not depend on remote services. The present `/api/healthz` can serve this purpose if it is named/documented as liveness only.
2. **Readiness**: the instance can accept the traffic assigned to it. A bounded probe must include at least PostgreSQL connectivity/schema compatibility, Feishu listener authenticated/connected state for ingress-serving instances, required runtime configuration, exact `cph` artifact availability/version, and workspace root access/capacity. A failed dependency makes readiness non-2xx without killing the process and losing diagnostics.
3. **Startup/deploy acceptance**: migrations/preflight complete, listener reaches ready state within a deadline, one authenticated HTTP/database smoke path and one controlled runtime/workspace smoke path pass, and the deployed release/schema identity matches the intended version. Only then is traffic enabled and the previous release eligible for cleanup.
Readiness responses should expose only component names, stable state/error codes, and timestamps—not secrets or raw exceptions. Full diagnostic context belongs in correlated restricted logs. The deployment audit must decide whether Feishu and HTTP run in one instance role; if roles are split later, readiness dependencies become role-specific rather than globally optional.
## Minimum metrics and alerts
Exact SLO values are product/operations decisions and are currently unspecified. The release nevertheless needs the following bounded-cardinality instruments and a reviewed initial threshold for each page/ticket alert.
### Traffic and failures
- HTTP request count, duration, active requests, status/error-code class, timeouts/cancellations.
- Feishu listener connected state, reconnect/auth failures, time since last successful connection/event.
- Ingress queue/receipt counts by durable state, processing attempts, oldest pending age, dead-letter count.
- Outbound delivery attempts/results/latency, retry/dead-letter count, oldest pending age, provider rate-limit responses.
- Prisma operation failures by normalized code/category and bounded critical-query duration; DB readiness result/latency.
### Workflow integrity and capacity
- Runs started and terminal outcomes, run duration, active/waiting/stale count and oldest age.
- Locks held/stale/orphaned and transition/reconciliation failures.
- Agent/provider failures by provider/model/error category; turns/tokens/cost as operational/business counters with tenant detail kept out of metric labels.
- `cph` invocation count/duration/result category/timeouts.
- Workspace free bytes/inodes, preflight failures, write/commit/reconciliation failures.
- Process uptime/restarts, event-loop delay, memory, CPU, file descriptors, unhandled rejection/uncaught exception, graceful shutdown outcome.
- Release/build/schema identity as an info metric or startup event, never as an unbounded label set.
### Required release-time alerts
Before production traffic, alerts and routing must exist for:
- instance readiness false beyond the rollout/startup grace period;
- Feishu auth failure or prolonged listener disconnect;
- sustained server-side HTTP errors or request timeouts;
- database readiness failure / critical Prisma failure spike;
- oldest ingress/outbound item, active run, or lock exceeds its decided deadline;
- dead-letter count non-zero or durable state-transition/reconciliation failure;
- agent/provider or `cph` failure/timeout spike;
- workspace disk/inodes below headroom threshold or workspace preflight fails;
- restart loop, failed deployment, failed migration, or rollback;
- telemetry pipeline blind (expected heartbeat/scrape/log ingestion absent).
Each alert needs an owner, severity, user impact statement, dashboard/log query, first safe diagnostic command, mitigation, escalation criterion, and recovery verification. A page without a runbook is not a production control.
## Incident recovery runbooks required before release
At minimum, checked-in runbooks must cover these scenarios. Commands must default to read-only inspection; destructive/redrive/force-release actions require explicit confirmation, identify tenant/project/run scope, emit an audit record, and be idempotent or document why they are not.
1. **PostgreSQL unavailable/degraded** — identify connection vs credential vs migration vs capacity failure; stop accepting work through readiness; restore service; reconcile active runs, locks, ingress receipts, and outbox; verify no raw errors escape HTTP.
2. **Feishu listener disconnected/credentials revoked** — distinguish auth from transient network/reconnect; restore credentials safely; verify last event and controlled inbound round trip; inspect ingress gap and redrive rules.
3. **Ingress backlog/dead letter** — list oldest/attempts/error by tenant-safe scope; decide retry vs reject; prove dedup/idempotency; verify one resulting run.
4. **Stuck/orphaned run or lock** — inspect run/session/lock/provider/workspace/audit/outbox together; use lease/reconciliation operation rather than direct row deletion; prove terminal state and next queued work behavior.
5. **Outbound delivery failure** — reconcile provider message ID and durable outbox; retry with idempotency key; confirm user-visible delivery without duplicating a card/message.
6. **Agent/provider or `cph` outage** — identify provider credential/rate-limit/runtime/protocol failure; halt or queue new runs; test a canary; resume and reconcile affected work.
7. **Workspace capacity/corruption/containment event** — quarantine affected project, snapshot evidence, restore/rollback documented unit, verify ownership/containment and repository state before resuming.
8. **Failed migration/deployment** — preserve old release, collect preflight/migration/readiness diagnostics, follow compatibility-aware application/database rollback, and verify release/schema identity plus canaries.
9. **Process/host restart during work** — drain when planned; after unplanned restart run durable reconciliation; verify receipts, runs, locks, workspace effects and deliveries converge exactly once at the business level.
10. **Suspected tenant data exposure** — disable affected path/tenant access without deleting evidence, preserve correlated audit/log records, rotate exposed credentials, determine affected org/project/users, and follow an explicitly owned incident-notification decision process.
## Minimum release evidence
Production readiness is not proved by a green unit suite alone. A release candidate needs an archived, attributable evidence bundle containing all of the following:
### Build and supply identity
- immutable application and `cph` artifact digests, source commit, build timestamp/toolchain, dependency lockfiles and vulnerability policy result;
- deployed configuration manifest listing required keys and instance role, with secret values redacted;
- migration set/schema identity and a compatibility statement for both forward deploy and rollback;
- systemd unit and installer/preflight version used on a clean host.
### Automated gates
- typecheck, complete unit/integration suite, migration-from-supported-version tests, and Lean `lake build` when `spec/` changes;
- clean-host install test with the real packaged artifacts and declared service user/directories;
- negative startup tests for missing/invalid env, DB unavailable, invalid Feishu credentials, missing/wrong `cph`, and unwritable/full workspace;
- readiness tests proving each dependency failure independently makes readiness fail while liveness remains diagnostically available;
- API error-mapping tests proving unexpected dependency exceptions return sanitized 5xx/stable codes and create a correlated error log;
- lifecycle fault tests from the run-lifecycle audit, including process kill/restart, DB transition failures, duplicate ingress, ambiguous outbound completion, timeout, and concurrent trigger/session creation;
- tenant isolation/security regression suite, symlink/root containment tests, and dependency audit policy gate.
### Staging drills against production-shaped dependencies
- authenticated Feishu inbound → durable receipt → one run → workspace effect → durable terminal state → one outbound confirmation, searchable end-to-end by a correlation ID;
- PostgreSQL socket cut/restoration while serving: readiness changes, alert fires, requests return sanitized server errors, state reconciles, alert clears;
- Feishu credential rejection/disconnect/reconnect: readiness/alert and controlled recovery are visible;
- SIGTERM and forced process kill during queued, active, and outbound-pending work: restart reconciliation proves no silent loss and no unintended duplicate user effect;
- `cph` timeout/non-zero/malformed response, provider throttle/error, workspace disk/permission failure, and outbound rate-limit/permanent failure drills;
- failed deploy/readiness and rollback drill on a clean host, followed by migration/data compatibility and canary verification;
- backup restore of representative database and workspace data, with measured recovery point/time and tenant isolation checks.
### Operations acceptance
- dashboards expose all release metrics above and show release/schema identity;
- every required alert is injected once in staging, reaches the named owner, links to its checked-in runbook, and clears only after verified recovery;
- log search can pivot from request/event to org/project/run/session/delivery without message content or secrets; retention and access controls are documented and tested;
- on-call ownership, escalation, incident severity, maintenance/rollback authority, backup ownership, and recovery objectives are explicitly decided;
- a signed release checklist records who reviewed each piece of evidence and where immutable test/drill outputs are stored.
## Prioritized remediation / release gates
### P0 — before any external production traffic
1. Implement distinct bounded liveness, readiness, and startup/deploy acceptance contracts; make deployment traffic activation depend on readiness.
2. Install centralized unexpected-error handling that returns sanitized stable 5xx responses and emits request-correlated structured errors. Never return raw Prisma/SDK/process exception text.
3. Make Feishu connection/auth state observable and readiness-relevant; make listener startup rejection fail the startup gate.
4. Implement the durable ingress, transactional run/lock/session, durable outbox, workspace commit/reconciliation, and durable audit/history work already opened by the lifecycle audit. Observability cannot compensate for acknowledged work that has no recoverable state.
5. Add end-to-end correlation and stable structured transition events; remove silent catches from critical paths.
6. Add the minimum metrics, alerts, dashboards, and checked-in recovery runbooks, then execute the fault/restart/rollback drills.
7. Replace in-place deployment with the clean-host audit's immutable release/readiness/rollback contract and prove it on a clean host.
8. Close the critical tenant/agent/workspace/Feishu authorization and dependency findings from the security audit before declaring readiness.
### P1 — required for routine operations immediately after the P0 architecture is in place
- Define initial SLOs/error budgets and tune alert thresholds using staging/load evidence.
- Add capacity/load/soak tests for DB connections, event-loop/process resources, ingress/outbound backlog, agent concurrency, provider limits, workspace disk/inodes, and log/metric pipelines.
- Exercise backup/restore, credential rotation, tenant-support trace lookup, and incident evidence preservation.
- Define data retention/deletion/access-control policy for logs, metrics, traces, audits, messages, receipts, deliveries, runs, and workspaces.
## Decision points that remain OPEN
The code/spec does not currently authorize choosing these product/operations semantics implicitly:
- readiness dependency roles (combined HTTP+Feishu process versus independently deployable roles);
- maximum acceptable ingress, run, lock, and outbound ages; retry budgets; terminal/dead-letter semantics;
- workspace mutation commit/rollback unit and what a user should observe after partial agent work;
- audit durability/availability trade-off and exact event taxonomy;
- SLOs, recovery point objective, recovery time objective, retention, regional/data-residency constraints, and incident-notification ownership;
- whether deploy migration compatibility must support application rollback across one or more releases.
These decisions should be recorded in the upstream contract/ADR layer before implementation when they affect domain-visible behavior. Operational mechanisms that only expose already-defined behavior can be implemented and tested without inventing domain semantics.
## Source notes
Primary local evidence:
- [`hub/src/server.ts`](../../../hub/src/server.ts), [`hub/src/admin/errors.ts`](../../../hub/src/admin/errors.ts) — process startup, health/listener/restart behavior, and HTTP exception-to-response mapping.
- [`hub/src/db.ts`](../../../hub/src/db.ts) — current Prisma event/log configuration.
- [`hub/src/feishu/client.ts`](../../../hub/src/feishu/client.ts), [`hub/src/feishu/trigger.ts`](../../../hub/src/feishu/trigger.ts), [`hub/src/feishu/messageBatcher.ts`](../../../hub/src/feishu/messageBatcher.ts), [`hub/src/feishu/triggerQueue.ts`](../../../hub/src/feishu/triggerQueue.ts) — ingress/listener, batching, dedup, queue, run and outbound behavior.
- [`hub/src/agent/runner.ts`](../../../hub/src/agent/runner.ts), [`hub/src/agent/roleTools.ts`](../../../hub/src/agent/roleTools.ts), [`hub/src/agent/cph.ts`](../../../hub/src/agent/cph.ts), [`hub/src/lock.ts`](../../../hub/src/lock.ts), [`hub/src/audit.ts`](../../../hub/src/audit.ts) — production SDK/Bash tool routing, the legacy direct cph wrapper, persistence, heartbeat/lock, and audit behavior.
- [`hub/prisma/schema.prisma`](../../../hub/prisma/schema.prisma) — durable state and available correlation fields.
- [`hub/deploy`](../../../hub/deploy) — installer/systemd/deploy behavior.
- [Clean-host deployment audit](./clean-host-deployment-audit.md), [tenant/auth security audit](./tenant-auth-security-audit.md), and [run-lifecycle integrity audit](./run-lifecycle-integrity-audit.md) — prior deterministic audits and their recorded commands/results.
Official references used only for interface semantics and implementation guidance:
- [Prisma Client error reference](https://www.prisma.io/docs/orm/reference/error-reference) and [Prisma logging](https://www.prisma.io/docs/orm/prisma-client/observability-and-logging/logging).
- [OpenTelemetry HTTP metric semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/).
- [systemd service types/readiness](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html) and [`sd_notify`](https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html).
No secondary production-readiness checklist was treated as authority. Recommendations are derived from the local failure evidence and the primary interfaces the service actually uses.
@@ -0,0 +1,272 @@
# Run lifecycle and data-integrity audit
## Verdict
The current Hub does **not** have a crash-safe or database-failure-safe run
lifecycle. The normal happy path is covered by tests and the database correctly
enforces one lock per project, but the surrounding workflow is a chain of
independent effects: inbound receipt, in-memory batching/queueing, session/run
creation, lock acquisition, workspace mutation, Feishu delivery, session cursor
update, run terminal update, audit write, and lock release.
There is no durable coordinator or reconciliation loop across those effects.
Deterministic fault injection confirmed that the current code can permanently
lose accepted work, leave a terminal run holding a lock, leave an `ACTIVE` run
without a lock, split one logical session into duplicate active rows, declare a
run complete without delivering its result, and create an audit entry referring
to no run. A hung provider call also has no wall-clock deadline or usable lease,
so one run can block a project until an operator restarts the service or edits
the database.
These are production blockers for the accepted single-host topology; they do
not require HA to reproduce.
## Contract baseline
The audit used the following upstream decisions and intentionally did not
invent semantics where the contract is open:
- `Spec.System.Run`: `active` and `waitingForUser` are non-terminal;
`completed`, `failed`, `timedOut`, and `canceled` are terminal.
- `Spec.System.Lock` and ADR-0002: each project has at most one lock, its owner
is an `AgentRun`, every lock holder is non-terminal, and the lock is released
when the run completes, fails, times out, or is canceled.
- ADR-0002: many runs reuse the same long-lived `AgentSession`.
- ADR-0017: the active session identity is project + provider + role + model,
and its provider cursor is persisted for the next run.
- `Spec.System.Audit`: every `AuditEntry` is associated with a run. Audit event
content, retention, and query dimensions remain `OPEN`.
- The contract does **not** decide whether filesystem changes made by a failed,
canceled, timed-out, or process-killed run should commit, remain as partial
work, or roll back. That choice is surfaced below rather than assumed.
## Positive evidence
The core happy path is real:
- PostgreSQL uniqueness enforces at most one `ProjectAgentLock` per project and
at most one project lock per run.
- `currentLockRunId` rejects a terminal run that still holds a lock instead of
silently treating it as healthy.
- Normal trigger completion updates the provider cursor, writes a terminal run
state, and releases the lock. Interrupt uses an `AbortController` and maps to
`CANCELED` on the normal path.
- A sequential Feishu redelivery with the same event ID is skipped, so the
ordinary duplicate case does not start a second run.
- The targeted lifecycle baseline passed before fault injection:
```text
npm run check
6 test files passed
71 tests passed
```
Those protections are necessary, but they only cover operations in which every
dependency succeeds in the expected order.
## Executed fault-injection evidence
A temporary Vitest harness exercised the real trigger, Prisma test database,
message batcher, and runner seams. It asserted the production invariant rather
than the current behavior. The harness was run repeatedly and produced the same
result each time; the final run was:
```text
npm test -- --run test/run-lifecycle-audit.repro.test.ts
Test Files 1 failed (1)
Tests 8 failed (8)
Duration 918ms
```
The throwaway harness was removed after diagnosis. Its eight minimal probes and
observed values were:
| Injected condition | Required invariant | Observed current result |
| --- | --- | --- |
| Authorization database error after Feishu receipt creation, followed by the same event redelivery | The failed event is retried | Authorizer calls remained `1`; redelivery was skipped |
| Batched-run setup callback throws, then `flushAll` retries | The pending batch remains recoverable | Callback attempts remained `1`; batch had already been deleted |
| Two active sessions with the same project/provider/role/model tuple | Database rejects the duplicate | Second insert succeeded |
| Audit insert uses a nonexistent run ID | Database enforces `AuditEntry → AgentRun` | Insert succeeded with arbitrary `runId` |
| Every Feishu message create/reply fails | Completion is not reported as delivered | Run became `COMPLETED`; delivered cards were `0` |
| First lock release delete fails | Terminal run is eventually unlocked | Lock count remained `1` after run became `COMPLETED` |
| Every terminal `AgentRun.update` fails | A non-terminal run retains/reacquires its lock | Run remained `ACTIVE`; lock count became `0` |
| Claude SDK returns `error_max_turns` | Runner returns `length`, trigger stores `TIMED_OUT` | Runner returned `failed` |
This is a tight reproduction at the actual call seams; none of the failures
depends on timing or external network access.
## Critical findings
### 1. Accepted Feishu work can be permanently lost
`trigger.ts` commits `FeishuEventReceipt` before message validation, binding
lookup, authorization, file download, command execution, batching, or run
creation. The receipt has no processing state, lease, attempt count, error, or
completion marker. Any later exception leaves a permanent row, so Feishu's
at-least-once redelivery is treated as already completed.
The two in-process buffers compound that behavior:
- `MessageBatcher.flushPendingBatch` deletes the batch before invoking its
callback. Timer-triggered callback failures are swallowed with
`.catch(() => undefined)`.
- `TriggerQueue` and `batchContexts` are process-local maps. A restart loses all
queued prompts and pending debounce batches. Queue draining removes an item
before run setup is durable, so a setup error also loses it.
There is no shutdown hook that flushes or rejects these buffers visibly. The
service can therefore acknowledge and forget user work even when PostgreSQL is
healthy again milliseconds later.
### 2. Run state and lock ownership can contradict each other
Run creation, lock acquisition, terminal update, and lock release are separate
Prisma calls. Their failure handling is asymmetric:
- a lock-acquisition race creates a session and an `AgentRun` first, then marks
the losing run failed;
- terminal update failure is caught, a second best-effort failure update is
attempted, and `finally` releases the lock regardless;
- lock release failure is only logged once, with no retry or reconciler.
Fault injection reached both forbidden outcomes: `COMPLETED + lock` and
`ACTIVE + no lock`. The first makes `currentLockRunId` throw the explicit
ADR-0002 invariant error on all later triggers; the second permits another run
to start while the database still claims the earlier run is live. In the
injected terminal-write case the earlier execution had already returned, but
the same unlocked/non-terminal shape created by an overlapping startup can
coexist with a still-running process.
Startup does not repair these states safely. `server.ts` first deletes **all**
locks and then, in a separate statement, marks only `ACTIVE` runs failed.
`WAITING_FOR_USER` is left non-terminal and unlocked, contrary to the contract.
The two statements are not transactional, and running a second Hub process
would destructively clear the live first process's locks and fail its runs.
### 3. A provider hang can block a project indefinitely
`maxTurns` is passed to the SDK, but there is no wall-clock run deadline. The
SDK max-turn result is currently mapped to runner status `failed`, making the
trigger's `length → TIMED_OUT` branch unreachable for the actual max-turn case.
`ProjectAgentLock.expiresAt` is never populated or consumed. `heartbeatAt` is a
best-effort update only after an assistant message, not a periodic heartbeat,
and there is no stale-lock/run sweeper. A query that stalls before its first
assistant message remains `ACTIVE` while holding the project lock forever.
`WAITING_FOR_USER` is declared but never written; approval waits remain
`ACTIVE` in an in-memory `ApprovalManager`.
Systemd sends `SIGTERM`, but the application has no signal handler, listener
close, in-flight drain, run cancellation, Prisma disconnect, or final recovery
transaction. A subsequent successful restart happens to fail `ACTIVE` runs,
but that is not a graceful lifecycle and does not cover all non-terminal
states.
### 4. Workspace effects have no defined commit or recovery boundary
The Claude SDK's built-in Write/Bash tools mutate the project workspace while
the run is still `ACTIVE`. A process kill, timeout, cancellation, database
failure, or later delivery failure can therefore leave real filesystem changes
behind while the database says the run failed or never completed.
No runtime code writes `AgentFileChange`: the only capture callback is in the
legacy AI-SDK `writeFileTool`, while the production runner uses Claude SDK
built-ins and never injects that collector. The schema comment claiming these
changes are captured for review is not true. The transcript comments are also
stale: the production runner neither loads nor appends the JSONL transcript;
continuity comes from the provider cursor.
`AgentMessage` writes are best-effort and silently swallowed. The session
cursor, message projection, workspace state, run terminal state, and audit
history can consequently describe different versions of the same execution.
Before implementing a mechanism, the product/contract must decide whether
partial workspace changes are committed, quarantined, or rolled back.
## High findings
### 5. Session identity and cursor continuity are not concurrency-safe
`startAgentRun` performs `findFirst` then `create` for the active
project/provider/role/model session before acquiring the project lock. The
schema has only a non-unique index for that tuple. The database probe inserted
two active rows with the same identity.
Concurrent triggers can therefore fork a logical conversation before one loses
the project-lock race. Future `findFirst` selection is unspecified and may
resume the wrong provider cursor. On the completion path, the session cursor is
updated separately and before the run terminal update, so a partial database
failure can advance the conversation while leaving the originating run active
or failed.
### 6. Outbound delivery is silent and not recoverable
The Feishu send helpers catch message errors and return `null`; `sendText`
returns `void`. `StreamingAgentCard.finish` treats a failed create/patch as a
boolean and normal run completion proceeds. The fault probe recorded a
`COMPLETED` run with zero delivered cards.
There is no durable outbox, provider message ID record, delivery state,
idempotency key, retry schedule, or reconciliation job. An operator cannot tell
whether the user saw the result, and retrying a larger workflow later would
have no durable key with which to prevent duplicate messages.
### 7. Audit rows violate the pinned relation and audit failures are invisible
`AuditEntry.runId` is nullable, has no foreign key, and accepts arbitrary
strings. This directly diverges from the pinned `Spec.System.Audit` relation
that every audit entry belongs to a run. Pre-run permission/security events are
valid product needs, but they require an explicit event model rather than
weakening the one relation the contract fixes.
`writeAudit` catches every Prisma error without logging, metric, retry, or
caller signal, and existing unit tests encode that swallowing as desired.
Lifecycle history can be absent exactly when PostgreSQL is unhealthy, with no
evidence that evidence was lost. The content and failure policy are `OPEN` and
need a decision, but silent unobservable loss is incompatible with operating
and debugging the service.
## Failure-mode summary
- **Process termination/restart:** loses batches, queued prompts, approvals,
active controller handles, and unrecorded message/file history; may leave
partial workspace changes; recovery ignores `WAITING_FOR_USER`.
- **Timeout/hang:** no wall deadline, periodic heartbeat, lease, or sweeper;
max-turn termination is mislabeled `FAILED` rather than `TIMED_OUT`.
- **Duplicate Feishu delivery:** sequential success is deduplicated, but any
failure after receipt becomes permanent loss; receipt check/create is also
not one transactional claim operation.
- **Database failure:** can produce either terminal-with-lock or
active-without-lock, lose session/message/audit facts, or advance only the
provider cursor.
- **Concurrent requests/processes:** the project-lock unique key prevents two
normal lock rows, but active sessions can fork; a second process's startup
reset can invalidate the first process's live lifecycle.
- **Outbound failure:** run completion and user-visible delivery are unrelated,
and no durable recovery/idempotency record exists.
## Required release evidence
Production readiness requires automated proof of all of the following:
1. Each accepted inbound event has a durable state machine and can be retried
after process/DB failure without duplicate run creation.
2. Queueing and batching either survive restart or have an explicit, observable
rejection/acknowledgement contract that cannot silently lose work.
3. Every database fault point preserves `lock exists ⇒ holder non-terminal`
and prevents a live non-terminal run from continuing without its lock.
4. A wall deadline, lease/heartbeat, watchdog, and startup reconciler converge
every non-terminal state, including human approval waits.
5. One active session identity is enforced in PostgreSQL and cursor advancement
is serialized with the run transition that produced it.
6. Feishu delivery uses a durable, idempotent outbox/reconciliation path and
exposes delivered, pending, and failed delivery states separately from model
completion.
7. The decided failed-run workspace policy is implemented and tested across
kill, cancel, timeout, and database failure; file/message history reflects
the real effects or explicitly reports missing evidence.
8. The audit/event model conforms to the pinned run relation, records pre-run
security events in an explicitly modeled place, and makes write loss
observable and recoverable according to the accepted policy.
9. Graceful shutdown and two-process/startup-race tests prove that one process
cannot clear another live process's locks or accept new work while draining.
@@ -0,0 +1,262 @@
# Tenant, authentication, and request security audit
## Verdict
The current Hub has a sound application-level foundation for ordinary org-admin
CRUD isolation, but it is **not safe to deploy as a multi-tenant SaaS**. Several
agent and asynchronous-action paths can cross the Organization/Project boundary,
and the current process-global credential design directly contradicts the
org-scoped encrypted-secret decision in ADR-0021.
The answer to the audit question is therefore **yes**: current filesystem,
Feishu-context, deferred authorization, card-action, and credential paths can
cross or bypass the intended boundary. These are implementation divergences,
not unspecified product behavior.
## Contract baseline
The audit used these upstream requirements:
- `Spec.System.Organization`: PROJECT and TEAM each have one Organization;
TEAM→PROJECT grants are same-org only.
- `Spec.System.ProjectWorkspace`: folders are transparent; folder/project
placement must remain inside one Organization.
- `Spec.System.Permission`: agent trigger requires EDIT, normal cancel requires
MANAGE, and force release is outside the project role lattice.
- `Spec.System.Memory`: a run may read Feishu context only from its project's
bound chat.
- `Spec.System.AgentSurface`: every agent file operation must stay inside that
run's project workspace.
- ADR-0019: authorization is evaluated against principals resolved at request
time so membership changes take effect immediately.
- ADR-0021: Feishu and model-provider credentials are org-scoped secrets,
encrypted at rest, and accessed through a resolver. Provider ownership may be
Organization-managed BYOK or a distinct platform-managed connection for that
Organization; neither mode permits a process-global shared key.
## Confirmed protections
The following paths are correctly fail-closed today:
- Every `/api/org/:orgSlug/*` route calls `requireOrgRole`; the guard reloads
the user, active membership, allowed OWNER/ADMIN role, and ACTIVE org for each
request.
- Project/session/folder/team service functions compare the requested object's
Organization before returning or mutating it. Cross-org lookups return not
found instead of revealing the foreign object.
- `PermissionAuthorizer` derives the Organization from the resource before
expanding team and external principals. `grantTeamProjectAccess` refuses a
TEAM whose Organization differs from the Project and detects corrupt active
grants when listing.
- Feishu messages with no sender `open_id` are denied. Bound-chat triggers run
through `agent.trigger` and `role.trigger` gates before creating an AgentRun.
- OAuth state is signed, expiry-checked, and paired with an HttpOnly nonce
cookie. `returnTo` is constrained to same-origin `/admin` paths. Session
signatures use HMAC-SHA256 with constant-time comparison, and session users
are reloaded on every request.
- Fastify's current default body limit is 1 MiB, so HTTP bodies are not
completely unbounded even though field-level and rate limits are absent.
Executed evidence:
```text
5 integration files passed
28 tests passed
```
Those tests include unauthenticated access, member-vs-admin roles, cross-org
project IDs, Organization-scoped team slugs, and cross-org TEAM grant refusal.
## Critical findings
### 1. The agent receives service secrets and can read other tenant data
`hub/src/agent/runner.ts` builds the SDK environment as:
```text
{ ...process.env, ...providerEnv }
```
The sandboxed agent and its Bash subprocess therefore inherit `DATABASE_URL`,
`FEISHU_APP_SECRET`, `HUB_SESSION_SECRET`, the model-provider token, and every
other service variable. A model or prompt injection can run `env`; network
egress is intentionally open, so exposed values can leave the host.
The same sandbox config has only `allowWrite: [workspaceDir]`. Reads outside
the workspace are allowed except for a small deny list. All organizations run
as the same service user, and sibling project workspaces are not denied. This
violates `AgentFileOp.Authorized`, which covers reads as well as writes.
The installed Claude Agent SDK already exposes two mechanisms that are not
used: sandbox credential rules can `deny` or proxy-`mask` environment variables,
and filesystem rules can deny a workspace root while re-allowing the current
workspace. Production still needs an actual Linux test proving that SDK/Bash
cannot read another project or service credential.
### 2. MCP file and Feishu-context tools escape their run objects
`resolveDeliverableFile` intentionally adds the repository Git root to allowed
roots, even when it is above the project workspace. It also checks lexical paths
with `stat`, which follows symlinks. A deterministic reproduction created
`workspace/leak.txt` as a symlink to another tenant's file:
```json
{"symlinkEscapeAccepted":true,"outsideContentReadable":true}
```
The checked-in unit test currently treats sending a repo-root `README.md` from
a nested project as desired behavior. That expectation is a direct divergence
from the AgentSurface contract and must be reversed.
`readFeishuContext` ignores its `ToolContext`. The MCP wrapper closes over the
bound `chatId`, but the model supplies an arbitrary message/thread ID and the
reader never verifies the returned message's `chat_id`. A stub Feishu API
returning a message from `chat-other` produced:
```json
{"returnedChatId":"chat-other","crossChatContentReturned":true}
```
The same validation is required for thread parents and every returned item.
Inbound downloads have the inverse symlink problem: the unsandboxed Hub writes
to `workspace/.cph/inbox`. A prior agent command can replace a path component
with a symlink, turning the Hub into a confused deputy unless writes use a
verified real path/no-follow boundary.
### 3. SaaS credentials are process-global instead of org-scoped
`server.ts` creates one process-global Feishu client/listener and passes one
global app ID/secret to OAuth. `feishuOAuth.ts` explicitly calls per-org
encrypted credentials "deferred", although ADR-0021 says they **must** be
org-scoped, encrypted at rest, and accessed through a resolver.
`EnvRuntimeSettings.provider` similarly discards its `scope` argument and uses
one global OpenRouter token for every project. This erases the accepted
per-Organization provider mode and makes both BYOK and platform-managed cost
attribution false. The schema has no encrypted credential store, key version,
rotation state, or resolver seam.
This is also a product reachability blocker: one customer-owned Feishu app
cannot receive bot/OAuth traffic for unrelated customer tenants under the
ADR-0021 model.
## High findings
### 4. Delayed and card actions are not bound to the authorized object
- `onMessage` checks `agent.trigger` before enqueueing/batching, but
`startAgentRun` checks only `role.trigger`. If a grant, team membership, or
tenant status changes while a request waits, it still starts. This contradicts
ADR-0019's request-time effectiveness rule.
- Interrupt handling authorizes `agent.cancel` against the project bound to the
card action's current chat, then calls `activeRuns.get(runId)` without proving
that `runId` belongs to that project. A stale, forwarded, or otherwise
mismatched action can abort another project's active controller.
- Approval handling stores the expected `chatId` but never compares it to the
action context before resolving the pending approval.
Execution-time authorization must resolve one immutable tuple
`(organization, project, run/action, actor, chat)` and reject any mismatch
before mutation.
### 5. SUSPENDED organizations can still execute bound projects
HTTP org-admin guards and unbound-chat onboarding reject non-ACTIVE orgs, but
`PermissionAuthorizer.organizationForResource` returns only `organizationId`
and never checks status. A real database reproduction created a SUSPENDED org,
project, and EDIT grant:
```json
{"organizationStatus":"SUSPENDED","agentTriggerAllowed":true}
```
Project onboarding helpers also check membership existence without checking
Organization status. The same status must be enforced at the shared resource
authorization/onboarding seam, not independently in selected transports.
### 6. Production dependency graphs contain known vulnerabilities
`npm audit --omit=dev` exits 1 with two high findings:
- `@larksuiteoapi/node-sdk@1.68.0`
- transitive `axios@1.13.6`
The current latest Feishu SDK (`1.70.0` at audit time) still pins Axios
`~1.13.3`, while the audited fixed Axios line is newer. The relevant advisories
include request/credential manipulation, proxy credential leakage, ReDoS, and
unbounded resource allocation; for example
[GHSA-3g43-6gmg-66jw](https://github.com/advisories/GHSA-3g43-6gmg-66jw),
[GHSA-hfxv-24rg-xrqf](https://github.com/advisories/GHSA-hfxv-24rg-xrqf), and
[GHSA-777c-7fjr-54vf](https://github.com/advisories/GHSA-777c-7fjr-54vf).
An override is outside the SDK's declared semver range and therefore requires
integration testing rather than a blind lockfile edit.
`cargo audit` exits 1 with five vulnerability records:
- `crossbeam-epoch@0.9.18`, patched in `>=0.9.20`;
- `quick-xml@0.38.4` and `0.39.4`, each affected by namespace-allocation and
quadratic-attribute DoS advisories, patched in `>=0.41.0`.
It also reports `memmap2@0.9.10` as unsound (patched in `>=0.9.11`) and four
unmaintained transitive crates. All enter through the Typst 0.15 dependency
graph. References:
[RUSTSEC-2026-0204](https://rustsec.org/advisories/RUSTSEC-2026-0204.html),
[RUSTSEC-2026-0194](https://rustsec.org/advisories/RUSTSEC-2026-0194.html),
[RUSTSEC-2026-0195](https://rustsec.org/advisories/RUSTSEC-2026-0195.html), and
[RUSTSEC-2026-0186](https://rustsec.org/advisories/RUSTSEC-2026-0186.html).
No `npm audit` or `cargo audit` gate exists in CI.
## Browser/session and availability gaps
These do not currently prove a cross-org data read, but they are required before
exposing the service publicly:
- state-changing cookie-auth routes have no CSRF token or Origin/Sec-Fetch-Site
enforcement; SameSite=Lax is the only browser-side barrier;
- there is no session revocation/version store, so a stolen cookie remains valid
for up to seven days unless the global secret rotates;
- startup accepts any non-empty session secret rather than enforcing generated
entropy, and there is no key rotation scheme;
- no security-header policy is registered, and Fastify request timeout is zero;
- OAuth, login, and org APIs have no per-IP/user/tenant rate limit;
- Feishu file/post download count, response size, MIME, archive shape, and total
workspace quota are unbounded; uploads read entire files into memory.
The exact abuse quotas and file limits remain with
`Define initial abuse and capacity controls`; they must not be guessed here.
## Secret scan
All 377 tracked files were scanned for private-key blocks, common cloud/API-key
shapes, and non-empty assignments to the Hub's principal secret variables. No
private key or real credential was found. The only assignment hits were the
documented `<OpenRouter API key>` placeholder and local/test PostgreSQL URLs.
`hub/.env` is ignored and untracked.
This is positive repository hygiene, but it does not mitigate the runtime
environment leak into the agent process.
## Required security release evidence
Production readiness requires automated proof of all of the following:
1. An agent/Bash process cannot read service credentials, another project
workspace, repo-root files, or a symlink target outside its workspace.
2. Feishu context and file delivery validate the real target after resolution,
not only a caller-supplied lexical ID/path.
3. Every delayed action reauthorizes at execution and binds actor, chat,
organization, project, run, and pending action consistently.
4. ACTIVE Organization status is enforced in the shared authorization seam.
5. Feishu and provider credentials resolve by Organization, are encrypted at
rest with versioned keys, never enter logs/agent tools, and can rotate;
provider resolution honors either Organization-managed BYOK or a distinct
platform-managed connection for that Organization.
6. Cross-org HTTP and Prisma service tests remain green, while new negative
tests cover every finding above.
7. Production Node and Rust dependency audits pass with no ignored
vulnerability/unsound advisory unless a time-bounded, reviewed exception is
explicitly recorded.
8. Browser/session, request, and file-ingestion limits are verified against the
accepted production threat and capacity policy.
@@ -0,0 +1,33 @@
# Audit the clean-host deployment and rollback contract
Type: research
Status: resolved
## Question
What concrete defects prevent a clean, supported Linux host from installing,
starting, health-checking, upgrading, and rolling back the current Hub without
manual repository knowledge or unsafe partial deployment?
## Answer
The current path is a happy-path updater for a manually prepared host, not a
production deployment mechanism. The executed audit found an unrunnable seeded
configuration, an unprovisioned service identity and storage, a missing `cph`
artifact, in-place mutation before build success, liveness-only verification,
no coordinated application/schema rollback, and a configured bind host that the
server ignores.
The complete evidence, operational consequences, required release invariants,
and verification scenarios are recorded in the
[clean-host deployment and rollback audit](../assets/clean-host-deployment-audit.md).
The accepted single-host topology remains suitable. The route forward is now
explicit in these implementation tickets:
- [Provision a runnable non-root Hub service](10-provision-nonroot-service.md)
- [Package and verify cph with every Hub release](11-package-cph-with-release.md)
- [Make application releases atomic and rollbackable](12-atomic-releases-and-rollback.md)
- [Add real readiness and an accurate service lifecycle](13-real-readiness-and-lifecycle.md)
- [Establish migration compatibility and failed-migration recovery](14-migration-compatibility-and-recovery.md)
- [Test clean-host deployment and rollback on Linux](15-test-clean-host-deployments-on-linux.md)
@@ -0,0 +1,40 @@
# Audit tenant, authentication, and request security boundaries
Type: research
Status: resolved
## Question
Can any current HTTP, Feishu, filesystem, agent, or database path cross an
Organization boundary, bypass the intended platform/org/project authorization
seams, expose secrets, or accept unbounded hostile input in the initial
production topology?
## Answer
Yes. Basic org-admin HTTP/Prisma scoping is correctly enforced and its 28
targeted integration tests pass, but the agent inherits service secrets and can
read sibling files, MCP file/context tools accept real targets outside the run
objects, queued/card actions do not consistently bind or reauthorize their
target, non-ACTIVE tenants can still trigger agents, and Feishu/provider
credentials remain global instead of ADR-0021 org-scoped encrypted secrets.
Current Node and Rust dependency audits also fail on high/vulnerability/unsound
advisories.
The complete contract mapping, reproductions, positive evidence, advisory
inventory, and required release proofs are in the
[tenant, authentication, and request security audit](../assets/tenant-auth-security-audit.md).
The newly-clear implementation and decision frontier is:
- [Confine the agent runtime and protect service credentials](16-confine-agent-and-protect-credentials.md)
- [Close MCP context and file-delivery escape paths](17-close-mcp-data-egress-escapes.md)
- [Bind and reauthorize deferred and card actions](18-bind-and-reauthorize-deferred-actions.md)
- [Design the org-scoped secret and connection control plane](19-design-org-scoped-secret-control-plane.md)
- [Design the production browser and session boundary](20-design-production-web-session-boundary.md)
- [Clear dependency advisories and gate releases](21-clear-dependency-security-advisories.md)
- [Enforce Organization status at the shared authorization seam](22-enforce-organization-status.md)
Inbound Feishu file count/size/MIME and request-rate policy remain owned by
[Define initial abuse and capacity controls](05-define-abuse-capacity-controls.md)
rather than being guessed in this audit.
@@ -0,0 +1,38 @@
# Audit run lifecycle and data integrity under failure
Type: research
Status: resolved
## Question
Under process termination, restart, timeout, duplicate Feishu delivery,
database failure, or concurrent requests, which AgentRun, lock, session,
workspace, audit, and outbound-message invariants can be violated or leave work
lost, duplicated, or permanently stuck?
## Answer
The happy path and database lock uniqueness work, but the lifecycle is not
crash-safe. Deterministic fault injection confirmed permanent inbound loss,
terminal runs retaining locks, active runs losing locks, duplicate active
sessions, completion without outbound delivery, unconstrained audit run IDs,
and max-turn termination being stored as failure. Process-local queues,
non-transactional startup recovery, unused leases, silent audit/history writes,
and direct workspace mutation add restart and indefinite-hang failure modes.
The complete contract mapping, eight fault probes, recovery analysis, and
required release evidence are in the
[run lifecycle and data-integrity audit](../assets/run-lifecycle-integrity-audit.md).
The newly-clear implementation and decision frontier is:
- [Make Feishu intake and queued work durable](23-durable-feishu-inbox-and-queue.md)
- [Make run, session, lock, and recovery transitions safe](24-transactional-run-lock-recovery.md)
- [Make outbound Feishu delivery durable and idempotent](25-durable-outbound-delivery.md)
- [Decide workspace commit semantics for failed runs](26-decide-workspace-run-commit-semantics.md)
- [Reconcile the audit and run-history contract](27-reconcile-audit-history-contract.md)
Execution-time authorization remains owned by
[Bind and reauthorize deferred and card actions](18-bind-and-reauthorize-deferred-actions.md),
while cross-component metrics, alerts, and operator runbooks remain owned by
[Define production observability and incident recovery](04-define-observability-recovery.md).
@@ -0,0 +1,47 @@
# Define production observability and incident recovery
Type: research
Status: resolved
Blocked by: 01, 02, 03
## Question
Which logs, metrics, readiness signals, audit correlations, alerts, and operator
recovery actions are missing for a production operator to detect and diagnose
failures across HTTP, Feishu, PostgreSQL, agent execution, cph builds, and
workspace storage?
## Answer
The Hub has useful fragments—Fastify access logs, Prisma error/warn output,
durable AgentRun facts, selected Feishu warnings, explicit migrations, and a
systemd journal—but no joined operational contract. It has no metrics or alert
surface, no end-to-end request/event/run/delivery/release correlation, no
checked-in recovery runbooks, and many critical writes or sends are silently
best-effort.
Three process-level fault probes confirmed the release gate is actively
misleading: cutting every PostgreSQL socket left `/api/healthz` at 200 twice;
an SDK-rejected Feishu app ID also left health at 200; and a runtime database
outage made `/api/me` return a raw Prisma invocation as `400 bad_request` with
no request-scoped exception log. The current deploy check can therefore accept
an instance unable to serve either database or Feishu work, while dependency
failures evade normal 5xx alerting.
The full failure-to-signal matrix, liveness/readiness/startup contract,
correlation envelope, metric and alert inventory, required runbooks, executed
probe evidence, primary sources, and minimum release evidence are in the
[production observability and incident-recovery audit](../assets/production-observability-recovery.md).
The newly-clear implementation and decision frontier is:
- [Make HTTP dependency failures typed and observable](28-typed-observable-http-errors.md)
- [Decide the telemetry, SLO, retention, and alerting control plane](29-decide-telemetry-slos-alert-routing.md)
- [Instrument correlated production runtime telemetry](30-instrument-correlated-runtime-telemetry.md)
- [Write and drill production incident recovery runbooks](31-write-and-drill-incident-runbooks.md)
[Add real readiness and an accurate service lifecycle](13-real-readiness-and-lifecycle.md)
owns component probes, Feishu connection state and deploy gating. Durable
ingress/run/outbound recovery stays with tickets 2325, audit semantics with
ticket 27, and backup/restore guarantees with ticket 06; this audit does not
duplicate those implementations.
@@ -0,0 +1,43 @@
# Define initial abuse and capacity controls
Type: grilling
Status: resolved
Blocked by: 02, 03
## Question
Which request limits, file limits, concurrency limits, agent budgets, tenant
quotas, and backpressure policies are mandatory for the accepted initial
production boundary, and which values or behaviors remain product decisions?
## Answer
The initial production service requires layered, fail-closed capacity controls:
every mandatory dimension has a versioned non-overridable platform ceiling,
while an Organization may configure only a lower policy limit. Agent work uses
a durable bounded queue, fair scheduling across Organizations, FIFO within an
Organization, explicit overload/expiry/cancellation states, and no automatic
whole-run replay after execution starts.
Hard controls cover request rate/body size, concurrency and queueing, files and
archive expansion, Project/Organization storage and entity counts, run/tool
budgets, and process resources. Reaching a limit blocks new growth without
deleting or hiding existing data. Token and provider cost remain attributed
soft-alert signals in the pilot, not hard spend controls; unknown cost is not
zero. Platform administrators also receive audited `DRAIN` and `STOP_NOW`
workload brakes.
The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
ceilings remain open until production-like calibration.
The implementation frontier is:
- [Implement layered limits and multi-dimensional request rate limiting](32-layered-limits-rate-limiting.md)
- [Build the durable Organization-fair admission scheduler](33-durable-fair-admission-scheduler.md)
- [Enforce file, archive, storage, and entity budgets](34-file-storage-budgets.md)
- [Enforce Agent Run and process resource budgets](35-run-runtime-budgets.md)
- [Implement provider-mode usage attribution and soft alerts](36-usage-soft-alerts.md)
- [Add audited emergency workload brakes](37-emergency-workload-brakes.md)
- [Calibrate and prove production capacity ceilings](38-calibrate-production-capacity.md)
@@ -0,0 +1,48 @@
# Audit backup, migration, and disaster recovery safety
Type: research
Status: resolved
## Question
What backup, restore, workspace/database consistency, migration compatibility,
and disaster-recovery guarantees are needed to deploy current persistent data
safely, and what evidence or tooling is missing to prove them?
## Answer
The Hub has no production backup/restore control plane. PostgreSQL can be
logically dumped and restored, but authoritative state is split across the
database, mutable Project workspaces, credentials/key material, and a
schema-compatible release with no shared checkpoint or recovery manifest.
RPO/RTO, retention, off-host/immutable storage, encryption/key custody,
full-service versus Organization-selective restore, monitoring, and recovery
drills are all undefined.
Executed probes proved that the current deploy `rsync --delete` removes the
documented in-tree workspace root, a failed Project transaction leaves an
orphan workspace, and an older application query fails after a destructive
migration. A deliberately failed Prisma migration rolled back that probe's DDL
but left a blocking failed migration record; the official contract does not
make PostgreSQL migrations transactional by default. A positive custom-format
round trip restored 24 tables and all nine current migration records, proving
only database-level portability on the local PostgreSQL 14.20 setup.
The complete state map, probe commands/results, root causes, required recovery
contract, and release evidence are in the
[backup, migration, and disaster-recovery audit](../assets/backup-migration-disaster-recovery-audit.md).
Official PostgreSQL, Prisma, systemd, and rsync semantics with claim-level links
are in [backup and migration primary sources](../assets/backup-migration-primary-sources.md).
The newly-clear decision and implementation frontier is:
- [Decide recovery objectives, backup retention, and restore scope](39-decide-recovery-objectives.md)
- [Implement coherent backup sets and verified restore](40-verified-backup-restore.md)
- [Make the Project workspace lifecycle recoverable](41-recoverable-project-workspace-lifecycle.md)
- [Establish migration compatibility and failed-migration recovery](14-migration-compatibility-and-recovery.md)
Persistent-path provisioning and release-tree isolation remain owned by
[Provision a runnable non-root Hub service](10-provision-nonroot-service.md)
and [Make application releases atomic and rollbackable](12-atomic-releases-and-rollback.md).
Full recovery/cutover procedures and drill evidence remain owned by
[Write and drill production incident recovery runbooks](31-write-and-drill-incident-runbooks.md).
@@ -0,0 +1,58 @@
# Audit the accepted product surface for production completeness
Type: research
Status: resolved
## Question
Against ADR-0020 and ADR-0021, which required platform-admin, org-admin,
project-onboarding, team/access, session, usage, provider-mode, Organization
policy-limit, emergency workload-control, and Feishu flows are absent,
unreachable, or only test-facing rather than usable in the initial production
service? Treat ADR-0022's capacity control surfaces as part of the accepted
product boundary.
## Answer
The current Hub is not a usable or production-complete SaaS product surface.
Its Organization-scoped JSON APIs and single-Feishu-app Agent Run path are
worth retaining, but the running service has one process-global Feishu
app/bot, one global provider key/base URL, no Organization connection/secret
plane, no platform-administrator control plane, and no real Organization or
platform management UI. OAuth redirects a successful login to an unmounted
`/admin/org/:orgSlug` route.
Two real Fastify integration probes, each run twice, proved that an authenticated
Organization ADMIN receives 404 at the post-login destination and two distinct
Organization return paths produce the same OAuth `client_id`. Official Feishu
documentation also proves that `open_id` is application-scoped, `user_id` is
tenant-scoped, and `union_id` only links applications from the same application
provider. The current globally unique `User.feishuOpenId`, raw `chatId`, event
identity, OAuth state and one-listener composition therefore cannot safely
route independently customer-owned applications.
ADR-0022 remains an additional release blocker: beyond a process-memory
Project queue and global max-turn setting, layered limits, durable
Organization-fair admission, hard storage/run/process budgets, alerts and
audited emergency brakes have no complete persistence or product surface.
The full verdict, deterministic probe results, root causes, retained seams and
required end-to-end release evidence are in the
[accepted SaaS product-surface completeness audit](../assets/product-surface-completeness-audit.md).
The detailed route/schema/test matrix is in the
[accepted product-surface code inventory](../assets/product-surface-code-inventory.md),
and the Feishu identifier/OAuth/event/long-connection facts are in the
[Feishu product-surface primary sources](../assets/product-surface-feishu-primary-sources.md).
The newly-clear execution frontier is:
- [Decide the platform-administrator identity and audit boundary](42-decide-platform-admin-identity-audit.md)
- [Implement the Organization connection and secret plane](43-implement-org-connection-secret-plane.md)
- [Route Feishu identity and traffic by Organization connection](44-route-feishu-by-org-connection.md)
- [Build the private platform-administrator control plane](45-build-private-platform-admin-control-plane.md)
- [Build the private Organization-administrator control plane](46-build-private-org-admin-control-plane.md)
Existing tickets 23 and 3238 remain the owners of durable ingress/admission,
capacity enforcement, usage alerts, workload brakes and calibration. Ticket 08
now requires all of these product slices before claiming the production release
gate.
@@ -0,0 +1,12 @@
# Prove the production release gate
Type: task
Status: open
Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46
## Question
After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test/spec health, deploy a clean
production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely?
@@ -0,0 +1,40 @@
# Restore the checker formatting gate
Type: task
Status: resolved
## Question
Apply the repository's configured rustfmt output to the currently reported
Rust files, verify that no semantic changes are introduced, and restore
`cargo fmt --all --check` so the checker CI gate is green.
## Answer
The exact CI command failed deterministically on seven Rust files and an
isolated single-file rustfmt check reproduced the same output. The source
changes were formatting-only, but the underlying gate was not reproducible:
the repository had no `rust-toolchain.toml` and checker CI installed floating
`stable`, so local rustfmt, clippy and CI could change independently.
The checker toolchain is now pinned to Rust 1.92.0 with `rustfmt` and `clippy`
in both `rust-toolchain.toml` and the Gitea workflow. The seven files were
formatted with that toolchain. Running the complete CI path then surfaced three
test helpers accepting `&PathBuf` instead of `&Path` and a Clippy name collision
between `Shell` and `PowerShell`; those were corrected without changing CLI
values or behavior.
Verification:
```text
cargo fmt --all --check PASS (twice)
cargo clippy --all-targets --all-features -- -D warnings
PASS
cargo test --all-features --workspace PASS (53 tests)
cph completions zsh / power-shell PASS
```
The final Rust diff consists of rustfmt layout plus four borrowed-Path
signature generalizations and the internal `Shell``CompletionTarget` type
rename. No expression, string value, command behavior, or public Rust API was
changed.
@@ -0,0 +1,40 @@
# Provision a runnable non-root Hub service
Type: task
Status: resolved
## Question
Make clean-host installation create or validate the dedicated service identity,
home/cache/state/workspace paths and ownership, generate a complete production
configuration, honor the configured bind host, and fail preflight before unit
installation when any required value or prerequisite is invalid. The persistent
workspace root must be outside all release trees, and preflight must reject any
path that overlaps rsync, release switching, rollback, or release pruning.
## Answer
The installer now creates or strictly validates a dedicated non-root,
non-login service identity with no supplementary groups; provisions its
home/state/cache/workspace directories at mode `0750`; and refuses persistent
paths that overlap the deployment tree after both lexical and physical path
resolution. A missing production environment file is seeded completely and
exits with status 78; invalid configuration or prerequisites fail before the
unit is written or enabled.
Preflight validates the configured bind host/port, authenticated PostgreSQL
access, Node/cph/Prisma, configured and canonical path traversal, and a real
bubblewrap namespace. The post-provision phase runs the built Hub dependency
graph, a database query, cph, socat and bubblewrap as the service user under
`no_new_privs`, matching the systemd security context. HTTP binding is awaited
before the Feishu listener starts, so bind failure rejects startup instead of
leaving a listener-only process alive.
Verification evidence:
- `npm run check`, `npm run build`, and `npm run prisma:validate`
- 166 unit tests and 86 integration tests passed; one external-model test skipped
- `bash -n deploy/install_service.sh` and `shellcheck deploy/install_service.sh`
- `prisma migrate deploy`: 9 migrations found, none pending
- production-mode HTTP smoke test on `127.0.0.1:8878` returned `/api/healthz`
- final Standards and Spec reviews found no remaining Issue 10 gap
@@ -0,0 +1,10 @@
# Package and verify cph with every Hub release
Type: task
Status: open
## Question
Deliver the compatible `cph` executable as part of the same immutable release
as the Hub, configure its exact path, and make deployment preflight reject a
missing, non-executable, or version-incompatible binary before restart.
@@ -0,0 +1,13 @@
# Make application releases atomic and rollbackable
Type: task
Status: open
Blocked by: 10, 11
## Question
Replace in-place rsync/build with deployment locking, immutable staged release
directories, a revision manifest, atomic current/previous switching, bounded
retention, and an explicit application rollback that preserves the live release
on build, preflight, switch, or readiness failure. Prove deploy, rollback, and
release pruning cannot traverse or delete the persistent workspace root.
@@ -0,0 +1,12 @@
# Add real readiness and an accurate service lifecycle
Type: task
Status: open
Blocked by: 03, 04, 10, 11, 12, 23, 24, 25
## Question
Separate liveness from readiness, verify every dependency needed to accept Hub
work, make deployment wait on readiness, and align systemd shutdown/restart
claims with an implemented and observable Fastify, Feishu, Prisma, and in-flight
run lifecycle.
@@ -0,0 +1,15 @@
# Establish migration compatibility and failed-migration recovery
Type: task
Status: open
Blocked by: 06
## Question
Define and enforce the application/schema compatibility rule for each release,
require expand-and-contract sequencing for destructive changes, add migration
status/checksum and production-like rehearsal gates, and move schema mutation
out of ordinary service start into an explicit release phase. Require a recent
verified recovery checkpoint, bounded lock/runtime evidence, and an operator
procedure for resolving or restoring a failed Prisma migration without
pretending that an application rollback reverses the database.
@@ -0,0 +1,12 @@
# Test clean-host deployment and rollback on Linux
Type: task
Status: open
Blocked by: 10, 11, 12, 13, 14
## Question
Add an automated Linux production-like harness that executes the real install,
deploy and rollback paths against PostgreSQL and proves first install,
idempotence, failure atomicity, readiness failures, application rollback, and
deployment serialization.
@@ -0,0 +1,38 @@
# Confine the agent runtime and protect service credentials
Type: task
Status: resolved
## Question
Make the real Claude SDK/Bash execution surface uphold `AgentFileOp.Authorized`:
deny sibling tenant workspaces and service files, expose only the minimum safe
process environment, use SDK credential protection for secrets, and add an
actual Linux sandbox test proving another workspace and every non-provider
credential are unreadable while required tools still work.
## Answer
The Agent subprocess now receives a replacement environment containing only
named runtime values and the selected provider protocol values; PostgreSQL,
Feishu and Hub session credentials never cross the process boundary. Provider
credentials are additionally denied inside Bash. The SDK must sandbox every
command (`failIfUnavailable=true`, `allowUnsandboxedCommands=false`), denies
host reads and writes from root, and re-opens only the canonical project
workspace plus the reviewed read-only system runtime needed by `cph`.
SDK config/cache/home and general temp storage live inside the workspace. A
short relative `CLAUDE_CODE_TMPDIR` prevents the pinned SDK from falling back
to host `/tmp`, while absolute workspace-local `TMPDIR`/`TMP`/`TEMP` remain
stable after Bash changes directory. Linux production and CI now require both
bubblewrap and `socat`; preflight probes both as the service user. Bounded,
credential-redacted SDK stderr is logged with run and project correlation.
The real Linux proof runs the actual Claude SDK, Bash, bubblewrap, `socat` and
release `cph` as UID 1000 with no capabilities and `no_new_privs`. It proves
parent, sibling-project, `/tmp` and `/var/tmp` writes are rejected and have no
host effect; sibling/service-secret reads fail; platform and provider secrets
are absent from Bash; workspace temp remains anchored after `cd`; and `cph`
still executes. Final local gates passed: TypeScript check/build, Prisma schema,
173 unit tests, 85 mock-provider integration tests, deployment shell syntax,
and the dedicated Linux sandbox test.
@@ -0,0 +1,28 @@
# Close MCP context and file-delivery escape paths
Type: task
Status: resolved
## Question
Restrict file delivery to a realpath/no-follow target inside the current project
workspace, remove Git-root access, make inbound Hub writes symlink-safe, and
validate every Feishu message/thread result against the run's bound chat before
returning data to the model.
## Answer
All Agent-facing workspace file reads and writes are anchored to the configured
workspace root and current project. Linux uses descriptor-relative, no-follow
traversal so a symlink swap cannot escape after validation; unsupported unsafe
paths fail closed. Inbound Feishu files use the same boundary. Outbound file
delivery no longer accepts the repository Git root or arbitrary absolute
paths: it reads a no-follow snapshot from the current project and uploads that
snapshot, so the checked object cannot be swapped before delivery.
The Feishu MCP read surface carries the run's project and bound-chat context.
Message and thread results are rejected unless their returned chat matches that
binding, and file-delivery MCP calls remain scoped to the same run/project/chat
tuple. Unit and integration regressions cover symlink paths, sibling projects,
Git-root delivery attempts, swapped files, and cross-chat results; they are
included in the final 173-unit/85-integration green gate reported by Issue 16.
@@ -0,0 +1,12 @@
# Bind and reauthorize deferred and card actions
Type: task
Status: open
Blocked by: 44
## Question
At execution time, reauthorize queued/batched triggers and validate one
actor/chat/organization/project/run-or-pending-action tuple for interrupt,
approval, and onboarding card actions so revoked permissions and mismatched
object IDs cannot affect another project.
@@ -0,0 +1,24 @@
# Design the org-scoped secret and connection control plane
Type: grilling
Status: resolved
## Question
Which key-management, envelope-encryption, rotation, connection identity, and
runtime resolver design will implement ADR-0021's org-scoped Feishu and model
provider credentials; support one customer-owned Feishu app per Organization;
support Organization-managed BYOK and a distinct platform-managed provider
connection per Organization; and ensure plaintext credentials never reach
business records, logs, or agent tools?
## Answer
Use the local master-key envelope, immutable connection-secret versions,
writer-authority split, explicit Organization/Project resolver, staged KEK
rotation, and separately protected keyring recovery contract accepted in
ADR-0024. Production receives the root-owned keyring only through systemd
`LoadCredential`; it has no process-global credential fallback. Agent runs use
a loopback proxy capability rather than the Organization Provider credential;
the offline rotation command atomically retains old/new KEKs, rewraps stored
DEKs, audits the changes, and authenticates the complete envelope set.
@@ -0,0 +1,14 @@
# Design the production browser and session boundary
Type: grilling
Status: open
## Question
What same-origin/CSRF, proxy/TLS, security-header, session lifetime,
revocation, secret rotation, and request-timeout contract should the initial
org-admin web surface enforce, and which parts require server-side session state
rather than the current seven-day stateless cookie? Apply the same browser
boundary to ADR-0023's separate server-side Platform Session while preserving
its distinct cookie/token namespace, immediate revocation, per-request grant
reload, and bounded recent-authentication requirement.
@@ -0,0 +1,44 @@
# Clear dependency advisories and gate releases
Type: task
Status: resolved
## Question
Remove every current `npm audit --omit=dev` high finding and every `cargo audit`
vulnerability/unsound finding through tested upgrades, compatible overrides or
upstream fixes—not ignores—and add reproducible Node and Rust security-audit
gates to CI and the production release evidence.
## Answer
All current production-high Node advisories and all Rust vulnerability/unsound
advisories are removed without advisory ignores:
- `@larksuiteoapi/node-sdk` 1.70.0 declares Axios `~1.13.3`, which prevented npm
from selecting a patched release. The Hub now has a targeted npm override to
Axios 1.18.1. The direct SDK stays current; the override is locked and the
full Hub type/build/test gate validates the resolved tree. A deterministic
local-HTTP smoke also drives the real SDK/Axios stack through tenant-token,
authenticated success and HTTP-error paths without external credentials.
- `memmap2` is upgraded to 0.9.11 and `crossbeam-epoch` to 0.9.20.
- `plist` is upgraded to 1.10.0, which moves its XML parser to `quick-xml`
0.41.0.
- crates.io `citationberg` 0.7.0 still pins vulnerable `quick-xml` 0.38.4.
Cargo now patches it to the exact upstream citationberg commit that adopts
`quick-xml` 0.41.0; the git revision is immutable in both manifest and lock.
`hub-check` runs the checked-in `npm run audit:production` command immediately
after `npm ci`. `checker-check` installs the pinned cargo-audit 0.22.2 release
with `--locked`, then runs `cargo audit --deny unsound`; vulnerabilities fail by
default and unsound warnings are promoted to failures.
Current evidence:
- `npm audit --omit=dev --audit-level=high`: 0 vulnerabilities.
- `cargo audit --deny unsound`: 0 vulnerabilities and 0 unsound warnings.
- Four Rust `unmaintained` informational warnings remain visible and are not
ignored; they are outside this issue's vulnerability/unsound acceptance
criterion and will continue to appear in CI output.
- Hub: type-check, build and 280 tests pass (10 environment-specific skips).
- Rust: fmt, clippy with warnings denied, and all 53 workspace tests pass.
@@ -0,0 +1,50 @@
# Enforce Organization status at the shared authorization seam
Type: task
Status: resolved
## Question
Make non-ACTIVE Organizations fail closed for project authorization,
onboarding, queued execution, and Feishu triggers through one shared seam, with
negative tests proving a SUSPENDED or ARCHIVED tenant cannot create, bind,
trigger, resume, or mutate work while operator recovery remains explicit.
## Answer
Organization lifecycle is now enforced at both authorization and mutation
linearization points:
- Project and project-group authorization resolves the owning Organization and
denies every customer action before principal resolution when status is not
`ACTIVE`. The legacy role helper follows the same rule.
- Every customer mutation service (project/folder/binding/policy, explorer,
members, teams, team grants, external-directory sync and slash-session
mutation) verifies `ACTIVE` while holding a PostgreSQL `FOR SHARE` lock on
the Organization row. A concurrent status `UPDATE` therefore orders before
the operation (which fails closed) or after its transaction commits.
- Agent session/run/project-lock admission is one transaction behind that same
lifecycle lock. Queued triggers reauthorize when drained. Slash-command races
return an explicit user-facing rejection instead of becoming deduplicated,
silent failures.
- Feishu attachments download into a private
`HUB_PROJECT_WORKSPACE_ROOT/.cph-staging` batch outside every project. A
durably committed ACTIVE run/project-lock admission is the linearization
point; publication then uses exclusive same-filesystem hard links rather than
copying bytes under a database lock. Failure compensates inode-tracked links,
fails the admitted run and releases its lock. Startup removes abandoned
staging batches and refuses staging symlinks.
- Project creation holds the lifecycle lock across workspace allocation and DB
creation. A later transaction failure removes the new leaf workspace; cleanup
failure is surfaced together with the original error as `AggregateError`.
- External-directory sync uses short lifecycle-serialized transactions per
unit rather than holding the Organization row lock across an unbounded sync.
No customer API can reactivate or otherwise change Organization lifecycle
state. Operator recovery remains a separate platform-control-plane task.
Evidence includes deterministic status/update lock ordering, direct-service
negative tests for both `SUSPENDED` and `ARCHIVED`, queued-trigger and
post-authorization races, DB-triggered workspace compensation (including
cleanup failure), full local Hub tests, and the real Linux attachment trigger
path.
@@ -0,0 +1,16 @@
# Make Feishu intake and queued work durable
Type: task
Status: open
Blocked by: 18, 44
## Question
Replace receipt-before-success and process-local batching/queueing with one
durable inbound work lifecycle that atomically claims duplicate events, records
processing/completion/failure, survives restart, retries recoverable failures,
and proves concurrent redelivery cannot duplicate a run or silently lose an
accepted prompt. Preserve execution-time authorization from ticket 18 when
deferred work is finally started, and expose the durable identity/state needed
by ADR-0022's Organization-fair admission scheduler rather than recreating a
second queue lifecycle.
@@ -0,0 +1,12 @@
# Make run, session, lock, and recovery transitions safe
Type: task
Status: open
## Question
Implement and fault-test a centralized AgentRun lifecycle that enforces one
active project/provider/role/model session, serializes provider-cursor updates,
preserves the ADR-0002 lock invariant across every database failure, applies a
real wall deadline and renewable lease, reconciles every non-terminal state on
startup/watchdog, and cannot let a second Hub process invalidate live work.
@@ -0,0 +1,13 @@
# Make outbound Feishu delivery durable and idempotent
Type: task
Status: open
Blocked by: 44
## Question
Give status cards, final responses, files, and recovery notices a durable
outbox/delivery lifecycle with stable idempotency keys, provider message IDs,
bounded retry and reconciliation, so model completion is distinguishable from
pending/failed/delivered user-visible output and an operator can safely replay
ambiguous failures without duplicates.
@@ -0,0 +1,13 @@
# Decide workspace commit semantics for failed runs
Type: grilling
Status: open
## Question
When an agent run fails, is canceled, times out, loses the database, or is
killed mid-tool, should its workspace changes remain as partial collaborative
work, be quarantined for review, or roll back atomically? Decide the contract,
including the relationship among provider cursor, AgentMessage,
AgentFileChange, git/filesystem state, and the next run, then create the
smallest implementation tickets and crash tests that enforce it.
@@ -0,0 +1,16 @@
# Reconcile the audit and run-history contract
Type: grilling
Status: open
Blocked by: 04
## Question
Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided
separate, append-only, fail-closed Platform Audit; decide only any shared
retention/export infrastructure without weakening either semantic boundary.
@@ -0,0 +1,13 @@
# Make HTTP dependency failures typed and observable
Type: task
Status: open
## Question
Replace message-substring HTTP error classification with typed validation,
domain, authorization, Prisma/dependency, and unexpected-error boundaries.
Return only sanitized stable client codes, preserve true 5xx/availability
semantics, emit one request-scoped structured error with a support-visible
request ID, redact secrets and raw internals, and add a real database-outage
regression proving Prisma failures can never become client 4xx responses.
@@ -0,0 +1,16 @@
# Decide the telemetry, SLO, retention, and alerting control plane
Type: grilling
Status: open
## Question
For the accepted initial single-host service, decide the log/metric/trace
collection and storage path, instance roles, release and correlation identity,
redaction/access/retention policy, initial service-level indicators and
objectives, error budgets, alert thresholds, on-call destinations, escalation
ownership, and telemetry-blind detection. Prefer the smallest operable control
plane, but record every domain-visible availability or retry assumption in the
appropriate ADR/spec rather than hiding it in dashboard configuration. Consume
ADR-0022's already-decided usage/cost soft-alert ownership as input; do not
reopen its hard-limit or provider-mode financial-alert semantics here.
@@ -0,0 +1,15 @@
# Instrument correlated production runtime telemetry
Type: task
Status: open
Blocked by: 13, 23, 24, 25, 27, 28, 29
## Question
Implement the decided telemetry control plane with one redacted structured
context from HTTP/Feishu ingress through durable work, run/session/lock,
provider and SDK retry/rate-limit/tool events, cph/workspace operations,
outbound delivery, audit, startup/drain/recovery, and release/schema identity.
Expose bounded-cardinality metrics, dashboards and alert rules; eliminate
silent critical-path failures; and prove logs can pivot end-to-end while metric
labels contain no tenant/user/run cardinality or secret/message content.
@@ -0,0 +1,22 @@
# Write and drill production incident recovery runbooks
Type: task
Status: open
Blocked by: 06, 12, 13, 14, 23, 24, 25, 26, 27, 28, 29, 30, 39, 40, 41
## Question
Check in operator runbooks for PostgreSQL outage/migration failure, Feishu
disconnect and credential rejection, ingress/outbound backlog or dead letter,
stuck run/lock, provider or cph failure, workspace capacity/corruption,
deploy/rollback, host restart during work, and suspected tenant exposure. Give
each alert an owner and safe read-first commands, require confirmed/audited
scope for destructive or replay actions, execute every runbook against
production-shaped staging, and archive evidence that detection, mitigation,
reconciliation, verification, escalation, and alert clearing all work. Include
database/workspace backup failure, failed migration, full-host restore and
cutover, duplicate/gap review, fallback, and achieved RPO/RTO evidence.
Also drill ADR-0023 platform-administrator lockout and Platform-owned Feishu
Application credential loss through the dual-factor offline recovery command,
expiring Emergency Platform Grant, session revocation, immutable Platform Audit,
and explicit recovery closeout; never introduce a standing break-glass account.
@@ -0,0 +1,13 @@
# Implement layered limits and multi-dimensional request rate limiting
Type: task
Status: open
Blocked by: 20, 22, 44
## Question
Implement ADR-0022's fail-closed platform ceilings, Organization-lowerable
policy limits, bounded list/query behavior, and typed multi-dimensional rate
limits for authenticated Web, signed Feishu, OAuth, and administrative writes.
Prove effective limits never exceed platform ceilings and every overload result
is observable, scoped, and carries retry guidance where applicable.
@@ -0,0 +1,13 @@
# Build the durable Organization-fair admission scheduler
Type: task
Status: open
Blocked by: 18, 23, 24, 32
## Question
Extend the durable inbound lifecycle into ADR-0022's bounded Agent Admission
Queue with platform/Organization/Project concurrency, fair scheduling across
Organizations, FIFO within each Organization, durable position/state,
expiration notification, scoped cancellation, idempotent admission, explicit
capacity rejection, and no automatic replay after a run has started.
@@ -0,0 +1,13 @@
# Enforce file, archive, storage, and entity budgets
Type: task
Status: open
Blocked by: 17, 23, 32
## Question
Enforce ADR-0022's request/file/attachment/archive-expansion,
Project/Organization storage, and Organization entity-count limits with
streaming accounting, atomic reservation, cleanup of incomplete artifacts,
bounded queries, explicit errors, and continued read/export/delete/admin
remediation while an Organization is at or above a ceiling.
@@ -0,0 +1,13 @@
# Enforce Agent Run and process resource budgets
Type: task
Status: open
Blocked by: 16, 24, 32
## Question
Implement ADR-0022's wall-time, turn, tool-count, per-tool-time,
output/log/event-size, memory, CPU, and child-process budgets. Termination must
be fail-fast and observable, persist `TIMED_OUT` or `LIMIT_EXCEEDED` with the
triggered dimension and actual usage, cancel the provider/process safely, and
release run locks and admission capacity under every injected failure.
@@ -0,0 +1,13 @@
# Implement provider-mode usage attribution and soft alerts
Type: task
Status: open
Blocked by: 19, 24, 29, 32, 43
## Question
Attribute token usage, provider-reported cost, run count, and duration by
Organization, Project, Run, model, and Provider Connection; preserve missing
cost as unknown; expose Organization-configurable soft thresholds; and route
Organization alerts plus platform-managed anomalous-cost alerts without hard
spend blocking, BYOK financial alerts, or payment-settlement semantics.
@@ -0,0 +1,13 @@
# Add audited emergency workload brakes
Type: task
Status: open
Blocked by: 22, 23, 24, 27, 30, 32, 33, 45
## Question
Implement reversible, reason-bearing, audited `DRAIN` and `STOP_NOW` controls at
Organization and platform scope. Prove new admissions, queued work, active runs,
locks, user notifications, authorization, telemetry, restart persistence, and
explicit recovery follow ADR-0022 without deleting or silently suspending
Organization data.
@@ -0,0 +1,14 @@
# Calibrate and prove production capacity ceilings
Type: research
Status: open
Blocked by: 30, 32, 33, 34, 35, 36, 37
## Question
On the accepted production-like host and provider path, measure safe numerical
defaults and headroom for every ADR-0022 platform ceiling. Exercise normal and
noisy-neighbor traffic, provider stalls and rate limits, queue saturation,
large files/archives, storage pressure, process budgets, and both workload
brakes; publish reproducible evidence and the checked-in configuration values
required by the production release gate.
@@ -0,0 +1,14 @@
# Decide recovery objectives, backup retention, and restore scope
Type: grilling
Status: open
## Question
For the initial SaaS service, decide measurable RPO/RTO targets, backup and
restore-drill frequency, retention and deletion-in-backups policy, off-host
copy/failure-domain requirements, logical export versus physical base-backup
and continuous-WAL/PITR coverage, encryption and key custody, operator ownership,
and whether the supported promise is full-service disaster recovery only or
also Organization-selective export/restore. Record compliance-dependent items
as explicit OPEN inputs rather than inventing defaults.
@@ -0,0 +1,16 @@
# Implement coherent backup sets and verified restore
Type: task
Status: open
Blocked by: 10, 12, 14, 19, 23, 24, 25, 26, 29, 39, 41, 43
## Question
Implement the decided recovery policy as cataloged, checksummed, encrypted,
off-host backup sets covering PostgreSQL logical exports plus physical base
backup/continuous WAL or a proven managed equivalent, Project workspaces,
compatible release/migration identity, configuration inventory, org-secret
ciphertext, and separately recoverable key material. Establish a proven
cross-store checkpoint, retention and alerting, partial-artifact cleanup,
isolated restore with outbound integrations disabled, lifecycle reconciliation,
critical SaaS journey verification, and measured RPO/RTO drills.
@@ -0,0 +1,14 @@
# Make the Project workspace lifecycle recoverable
Type: task
Status: open
Blocked by: 10, 26, 34
## Question
Move persistent workspaces outside every mutable release tree and make Project
workspace allocation, database commit, archive/deletion, relocation, restore,
and orphan/missing-path reconciliation explicit and crash-safe. Prove failed
Project creation leaves no silent orphan, deployment/rollback/pruning cannot
delete customer files, restored absolute paths are validated or safely rebased,
and every inconsistency is observable rather than auto-hidden.
@@ -0,0 +1,54 @@
# Decide the platform-administrator identity and audit boundary
Type: grilling
Status: resolved
## Question
Which identity issuer, bootstrap and recovery procedure, invite/allowlist model,
session claims, role lifecycle, separation from Organization membership, and
append-only audit contract will authenticate Platform Administrators for the
initial service? The decision must keep `/admin/platform` private, make every
Organization/connection/workload-control mutation attributable, and define a
reviewable break-glass path without treating platform staff as customer members.
## Answer
Platform Administrators authenticate only through a dedicated Platform-owned
Feishu Application and a separate, server-side Platform Session. Platform
Identity, standing grant, invitation, session, audit and emergency grant are
independent of customer `User`, `OrganizationMembership`, Project permission
and Project/Run audit. The initial control plane has one peer role only.
The first administrator is created by a versioned, idempotent, transactional
database bootstrap procedure that runs only when no standing administrator is
active and atomically records the Platform Audit Entry. Later administrators
receive short-lived, single-use invitations bound to a specific platform
Feishu account. The last active standing administrator cannot be revoked;
revocation immediately invalidates that identity's sessions and invitations.
Every platform request reloads the revocable session and current standing or
unexpired emergency grant. Administrator, connection/credential,
Organization-lifecycle, workload-brake and emergency-grant actions require
recent Feishu OAuth step-up.
Every successful platform mutation commits with a separate append-only
Platform Audit Entry or fails. Organization Administrators receive a redacted
projection of platform actions affecting their own Organization. No standing
break-glass account exists: offline recovery requires controlled host access,
an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant.
The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain
with [Design the production browser and session boundary](20-design-production-web-session-boundary.md).
Customer Project/Run audit retention remains with
[Reconcile the audit and run-history contract](27-reconcile-audit-history-contract.md),
without reopening Platform Audit's separate fail-closed boundary. Implementation
is owned by [Build the private platform-administrator control plane](45-build-private-platform-admin-control-plane.md),
and recovery rehearsal by [Write and drill production incident recovery runbooks](31-write-and-drill-incident-runbooks.md).
@@ -0,0 +1,16 @@
# Implement the Organization connection and secret plane
Type: task
Status: open
Blocked by: none
## Question
Implement encrypted, versioned, Organization-scoped Feishu Application and
model Provider Connection records plus one resolver boundary. Support one
customer-owned Feishu app per Organization, Organization-admin-managed BYOK,
and one distinct platform-managed key/base URL per Organization; enforce which
administrator may write each mode, readiness and rotation lifecycle, runtime
resolution from Organization/Project, redacted observability, backup/key
recovery hooks, and no process-global credential fallback or plaintext leakage
into business rows, logs, HTTP responses, or agent environments.
@@ -0,0 +1,17 @@
# Route Feishu identity and traffic by Organization connection
Type: task
Status: open
Blocked by: 20, 22, 43
## Question
Make every Feishu OAuth, WebSocket/event, card action, directory sync, chat
binding, API call, and outbound message start from an explicit active
Organization Feishu Application Connection. Model external user, chat, event,
and callback identifiers in that connection's namespace; bind OAuth state to
the intended Organization/application; run and observe one listener/client
lifecycle per configured app; and prove two customer-owned apps with colliding
provider-local identifiers cannot authenticate, bind, authorize, deduplicate,
rate-limit, or deliver across Organizations. Do not infer tenant from a global
`open_id`, `chat_id`, or from the user's membership count.
@@ -0,0 +1,25 @@
# Build the private platform-administrator control plane
Type: task
Status: open
Blocked by: 20, 27, 28, 42, 43, 44
## Question
Implement authenticated `requirePlatformAdmin` APIs and a private
`/admin/platform` web area for Platform Administrators to create/list/update
Organizations, bootstrap their OWNER, view/change lifecycle state, configure
and test each customer-owned Feishu Application, configure the Organization's
distinct platform-managed Provider Connection, and manage platform-admin
invitations and revocations. Implement ADR-0023's separate Platform Identity,
single standing role, guarded bootstrap procedure, identity-bound single-use
invitations, revocable server-side Platform Session, recent-authentication
step-up, last-administrator protection, fail-closed append-only Platform Audit,
Organization-safe audit projection, and dual-factor offline recovery with
expiring Emergency Platform Grants. Replace rather than reuse the legacy
customer-`User`-bound `PlatformRoleAssignment`/`TEACHER` model. Prove anonymous
users, ordinary members, and Organization admins cannot load either page data
or mutations; every sensitive action is validated, redacted, audited,
observable, and covered by browser-level critical-journey tests. Emergency
workload controls remain integrated through
[Add audited emergency workload brakes](37-emergency-workload-brakes.md).
@@ -0,0 +1,17 @@
# Build the private Organization-administrator control plane
Type: task
Status: open
Blocked by: 20, 27, 28, 32, 36, 43, 44
## Question
Turn the guarded Organization JSON APIs into a usable private
`/admin/org/:orgSlug` web control plane for OWNER/ADMIN users. Cover members and
roles, teams and Project access, Folders/Projects/chat unbinding, sessions and
usage, model/role/run policy, Organization policy limits and alerts, Feishu
connection readiness, Provider Credential Mode selection, and BYOK key/base URL
management without ever displaying stored plaintext. Preserve server-side
authorization on every request, define safe loading/error/empty states, and
prove login redirect plus browser journeys, cross-Organization denial, MEMBER
denial, secret redaction, and logout/session expiry end to end.
+62
View File
@@ -0,0 +1,62 @@
# Initial Production SaaS Readiness
Label: wayfinder:map
## Destination
The repository can be deployed from a clean checkout as the initial production
multi-tenant Curriculum Project Hub: platform staff manage Organizations; every
Organization authenticates through and operates its own customer-owned Feishu
app/bot, and selects either an Organization-managed BYOK provider connection or
a distinct platform-managed provider connection; Organization owners/admins
manage their own members, projects and settings. The ADR-0018/0020/0021
single-host Linux topology is operated with repeatable release evidence,
rollback and recovery, and no known critical security or data-integrity gaps.
## Notes
- `spec/` remains the semantic source of truth; implementation drift must be
surfaced rather than silently resolved.
- The initial product boundary follows the accepted ADRs: Organization is the
tenant root and customer onboarding may be manual. Full billing, tenant
suspension, and self-service platform administration remain deferred;
ADR-0022's safety limits and workload brakes are required for production.
- Manual guided setup may provision each Organization's customer-owned Feishu
app during the pilot; one process-global Feishu/OAuth credential shared by
unrelated Organizations is not an acceptable destination state.
- Use the `diagnosing-bugs` loop for every reproduced defect. Prefer explicit
failures and observable critical paths over fallback behavior.
- Work directly on `main` per developer authorization; make small conventional
commits after verified, independently reversible milestones. Do not push
without explicit authorization.
- Local `lake build` currently requires a configured elan toolchain; CI remains
the authoritative configured Lean build environment until that local toolchain
is available.
## Decisions so far
- [Audit the clean-host deployment and rollback contract](issues/01-audit-clean-host-deployment.md) — keep the accepted single-host topology, but replace the incomplete in-place updater with a provisioned, immutable, readiness-gated and rollbackable release contract.
- [Audit tenant, authentication, and request security boundaries](issues/02-audit-tenant-auth-security.md) — retain the working org-admin/application authorization core, but production is blocked on agent/MCP isolation, execution-time object binding, org-scoped encrypted credentials, tenant status, browser sessions, and clean dependency audits.
- [Audit run lifecycle and data integrity under failure](issues/03-audit-run-lifecycle-integrity.md) — retain the database-enforced project-lock uniqueness and normal happy path, but replace receipt-before-success, process-local queues, independent run/session/lock effects, silent delivery/audit loss, and partial startup reset with durable, recoverable state machines; explicitly decide failed-run workspace semantics.
- [Define production observability and incident recovery](issues/04-define-observability-recovery.md) — keep the existing Fastify/Prisma/systemd signal fragments, but production requires dependency-aware readiness, typed and correlated errors, durable-work telemetry, an explicit SLO/retention/alerting control plane, and recovery runbooks proven by fault drills.
- [Define initial abuse and capacity controls](issues/05-define-abuse-capacity-controls.md) — require layered fail-closed limits, durable Organization-fair admission, explicit backpressure, bounded files/storage/runs, soft usage alerts, and audited emergency workload brakes; calibrate numerical ceilings under production-like load.
- [Audit backup, migration, and disaster recovery safety](issues/06-audit-backup-migration-recovery.md) — separate persistent state from releases, define a database/workspace checkpoint and recovery objectives, make migrations compatibility-gated, and prove complete off-host backup sets through isolated restore and cutover drills.
- [Audit the accepted product surface for production completeness](issues/07-audit-product-surface-completeness.md) — retain the guarded Organization APIs and single-app Feishu vertical slice, but require explicit Organization-scoped Feishu/provider connections and external-identity namespaces, private platform and Organization admin control planes, and the full ADR-0022 capacity surface before release.
- [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account.
- [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green.
- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit.
- [Confine the agent runtime and protect service credentials](issues/16-confine-agent-and-protect-credentials.md) — replace the Agent environment, deny host reads/writes from root, keep every writable SDK path inside the canonical project, require bubblewrap plus socat, emit bounded correlated diagnostics, and prove the boundary with the real Linux SDK under a capability-free non-root identity.
- [Close MCP context and file-delivery escape paths](issues/17-close-mcp-data-egress-escapes.md) — use no-follow project-rooted file operations and delivery snapshots, remove Git-root delivery, and bind every Feishu MCP read to the run's project and chat.
- [Enforce Organization status at the shared authorization seam](issues/22-enforce-organization-status.md) — deny non-ACTIVE tenants before principal resolution, serialize every customer mutation and run admission against Organization lifecycle updates, reauthorize queued work, and stage/publish attachments with crash recovery and explicit filesystem compensation.
- [Clear dependency advisories and gate releases](issues/21-clear-dependency-security-advisories.md) — resolve Node production-high and Rust vulnerability/unsound advisories with locked compatible upgrades plus an exact upstream citationberg fix, and make both audits mandatory CI gates without ignores.
## Fog
- The production topology after the initial single-host pilot (HA, horizontal
workers, multi-region, or managed container orchestration) depends on measured
demand and failure experience.
- Commercial self-service concerns such as billing plans and automated tenant
provisioning remain beyond the accepted ADR boundary; revisit after the
initial safety and operability route is clear.
- Regulatory retention, residency, and customer-specific compliance controls
require product/legal inputs that the repository does not currently contain.
+45
View File
@@ -0,0 +1,45 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent skill 只来自 Hub release 内审核过的显式 allowlist,以 release-owned 只读 local
plugin 加载;`settingSources: []` 继续禁用项目/用户配置加载。不得把任意 workspace
`.claude` 配置或未审核 skill 变成运行时能力(见 ADR-0018)。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+5
View File
@@ -6,6 +6,11 @@
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律
+101
View File
@@ -0,0 +1,101 @@
# Curriculum Project Hub
The shared language for the multi-tenant service that lets customer organizations manage curriculum projects and operate agents through their own Feishu connection.
## Language
**Organization**:
The customer tenant root that owns its memberships, projects, teams, external connections, and settings.
_Avoid_: Account, workspace
**Platform Administrator**:
An internal operator who creates and operates Organizations without becoming a member of them.
_Avoid_: Organization admin, super member
**Organization Administrator**:
An Organization OWNER or ADMIN who manages only that Organization's members, projects, connections, and settings.
_Avoid_: Platform admin
**Admin Surface**:
The private web control plane available only to an authenticated user with the corresponding Platform Administrator or Organization Administrator authority.
_Avoid_: Public management page, member portal
**Customer-owned Feishu Application**:
The Organization-scoped Feishu application used for that Organization's login, bot messages, and directory integration; unrelated Organizations never share it.
_Avoid_: Global Feishu app, platform bot
**Platform-owned Feishu Application**:
The dedicated Feishu application used only to authenticate Platform Administrators; it is separate from every Customer-owned Feishu Application and grants no Organization or Project authority.
_Avoid_: Customer-owned Feishu Application, Organization Feishu app
**Platform Administrator Bootstrap**:
The one-time, guarded grant of Platform Administrator authority to a verified Platform Identity when no active Platform Administrator exists.
_Avoid_: Seed administrator, standing bootstrap account
**Platform Administrator Invitation**:
A short-lived, single-use invitation issued by a Platform Administrator and bound to one expected identity from the Platform-owned Feishu Application.
_Avoid_: Open administrator enrollment, unbound administrator invitation
**Platform Session**:
A revocable authentication session bound only to a Platform Identity and its active Platform Administrator authority; it carries no Organization membership or Project authority.
_Avoid_: Organization admin session, customer session
**Platform Audit Entry**:
An immutable, durable record of a platform control-plane decision or action, separate from customer Project and Agent Run audit and mandatory for every platform mutation. Organization Administrators receive only a redacted projection of entries affecting their Organization.
_Avoid_: Best-effort log, Project audit entry
**Emergency Platform Grant**:
Short-lived Platform Administrator authority issued only by the offline recovery procedure for a declared incident and automatically expired afterward.
_Avoid_: Standing break-glass account, permanent emergency administrator
**Provider Credential Mode**:
The Organization's choice between a BYOK Provider Connection and a Platform-managed Provider Connection.
_Avoid_: Global provider setting
**BYOK Provider Connection**:
An Organization-scoped model-provider connection whose key and base URL are supplied and managed by an Organization Administrator.
_Avoid_: Platform key
**Platform-managed Provider Connection**:
An Organization-scoped model-provider connection whose distinct key and base URL are supplied and managed by a Platform Administrator for that Organization.
_Avoid_: Shared platform key, BYOK
**Platform Capacity Ceiling**:
A non-overridable upper bound imposed by the platform to protect shared service capacity.
_Avoid_: Organization quota
**Organization Policy Limit**:
An optional lower bound configured by an Organization Administrator to govern that Organization's own usage.
_Avoid_: Platform limit
**Effective Limit**:
The lower of the Platform Capacity Ceiling and the Organization Policy Limit; when no Organization Policy Limit exists, the Platform Capacity Ceiling applies.
_Avoid_: Unlimited default
**Organization-fair Scheduling**:
The policy that allocates available agent execution capacity fairly across Organizations, while preserving first-in-first-out order within each Organization.
_Avoid_: Global FIFO
**Agent Admission Queue**:
The durable, bounded set of accepted agent run requests waiting for execution capacity.
_Avoid_: In-memory trigger queue, unlimited backlog
**Expired Run Request**:
An accepted agent run request that reached its maximum queue wait before execution; it is terminal and will never execute automatically.
_Avoid_: Delayed request, automatic retry
**Run Request Initiator**:
The Organization member whose action created an agent run request.
_Avoid_: Queue owner, Project owner
**Explicit Run Retry**:
A new agent run request deliberately created after an earlier run failed or timed out.
_Avoid_: Automatic replay, implicit rerun
**Run Budget**:
The deterministic resource boundary for one agent run, independent of monetary or token usage alerts.
_Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**:
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
_Avoid_: Organization deletion, service restart
Generated
+12 -21
View File
@@ -264,10 +264,9 @@ dependencies = [
[[package]]
name = "citationberg"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756ff1e3d43a9ecc8183932fb4d9fd3971236f3ce4acb62fe51d1cd43297547d"
source = "git+https://github.com/typst/citationberg?rev=06a591e2f237d25e1dfdedac3f3d1494c496c52d#06a591e2f237d25e1dfdedac3f3d1494c496c52d"
dependencies = [
"quick-xml 0.38.4",
"quick-xml",
"serde",
"serde_path_to_error",
]
@@ -427,6 +426,7 @@ version = "0.0.2"
dependencies = [
"cph-diag",
"serde",
"tempfile",
"toml",
]
@@ -480,9 +480,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1402,9 +1402,9 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "memmap2"
version = "0.9.10"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
@@ -1641,13 +1641,13 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "plist"
version = "1.9.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64",
"indexmap",
"quick-xml 0.39.4",
"quick-xml",
"serde",
"time",
]
@@ -1755,23 +1755,14 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.45"
+5
View File
@@ -21,3 +21,8 @@ serde = { version = "1", features = ["derive"] }
toml = "0.8"
cph-diag = { path = "crates/cph-diag" }
cph-model = { path = "crates/cph-model" }
# citationberg 0.7.0 on crates.io pins vulnerable quick-xml 0.38. Upstream has
# adopted quick-xml 0.41 but has not published a replacement release yet.
[patch.crates-io]
citationberg = { git = "https://github.com/typst/citationberg", rev = "06a591e2f237d25e1dfdedac3f3d1494c496c52d" }
+6 -1
View File
@@ -41,7 +41,8 @@ crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/R
cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
(hub/ exporter/ …) ← 将来的其他部件,平级于 spec/。尚未创建
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 spec/
```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
@@ -72,3 +73,7 @@ examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+29 -7
View File
@@ -360,8 +360,13 @@ pub fn target_is_shell(root: &Path, target: &str) -> bool {
.iter()
.find(|t| t.name == target)
.is_some_and(|t| {
t.steps.iter().any(|s| matches!(s, cph_model::Step::Shell { .. }))
&& !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::Shell { .. }))
&& !t
.steps
.iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
}
@@ -370,9 +375,17 @@ fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
use std::process::Command;
let output = if cfg!(target_os = "windows") {
Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output()
Command::new("cmd")
.arg("/C")
.arg(run)
.current_dir(cwd)
.output()
} else {
Command::new("sh").arg("-c").arg(run).current_dir(cwd).output()
Command::new("sh")
.arg("-c")
.arg(run)
.current_dir(cwd)
.output()
};
match output {
@@ -704,7 +717,11 @@ fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Resul
}
}
if let Err(e) = std::fs::copy(&src, &dst) {
return Err(format!("failed to copy '{}' → '{}': {e}", src.display(), dst.display()));
return Err(format!(
"failed to copy '{}' → '{}': {e}",
src.display(),
dst.display()
));
}
}
Ok(())
@@ -763,7 +780,8 @@ pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool {
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. }))
&& !t.steps
&& !t
.steps
.iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
@@ -833,7 +851,11 @@ fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
lesson
.targets
.iter()
.filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })))
.filter(|t| {
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
.map(|t| t.name.as_str())
.collect()
}
+14 -11
View File
@@ -5,7 +5,7 @@
//! fixtures built in a temp dir. The typst Engine is pointed at the repo's real
//! `render/` package via `Engine::with_render_dir`.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use cph_diag::DiagCode;
use cph_typst::Engine;
@@ -138,7 +138,7 @@ path = "segments/does-not-exist"
/// Write a throwaway one-segment lesson into `tmp`, with the given `[targets.x]`
/// body spliced in. Returns nothing; the caller runs `check`.
fn write_segment_lesson_with_target(tmp: &PathBuf, target_body: &str) {
fn write_segment_lesson_with_target(tmp: &Path, target_body: &str) {
std::fs::write(
tmp.join("manifest.toml"),
format!(
@@ -264,7 +264,7 @@ template = "exports/handout.typ"
/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the
/// given command. Returns nothing; the caller runs `run_shell_target`.
fn write_shell_target_lesson(tmp: &PathBuf, run: &str) {
fn write_shell_target_lesson(tmp: &Path, run: &str) {
std::fs::write(
tmp.join("manifest.toml"),
format!(
@@ -365,7 +365,10 @@ run = "printf SHOULD_NOT_RUN > build/ran.txt"
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(!report.ok);
assert!(report.outcomes.is_empty(), "no command should run on a broken lesson");
assert!(
report.outcomes.is_empty(),
"no command should run on a broken lesson"
);
assert!(report.check.has_errors());
assert!(
!tmp.join("build").join("ran.txt").exists(),
@@ -380,7 +383,7 @@ run = "printf SHOULD_NOT_RUN > build/ran.txt"
/// content file, plus a `slides` target that assembles them. The `which` slice
/// controls which segments get a `slides.md` (so the "skip absent" path can be
/// exercised). Returns nothing; the caller runs `run_markdown_assemble_target`.
fn write_markdown_assemble_target_lesson(tmp: &PathBuf, slides: &[(&str, &str)]) {
fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
let mut parts = String::new();
for (i, (name, _)) in slides.iter().enumerate() {
if i > 0 {
@@ -424,10 +427,7 @@ field = "slides"
#[test]
fn target_is_markdown_assemble_detects_shape() {
let tmp = tempdir();
write_markdown_assemble_target_lesson(
&tmp,
&[("a", "# A"), ("b", "# B")],
);
write_markdown_assemble_target_lesson(&tmp, &[("a", "# A"), ("b", "# B")]);
assert!(cph_check::target_is_markdown_assemble(&tmp, "slides"));
// The student target doesn't exist here → falls through to typst path → false.
assert!(!cph_check::target_is_markdown_assemble(&tmp, "student"));
@@ -439,7 +439,10 @@ fn run_markdown_assemble_target_concatenates_in_parts_order() {
let tmp = tempdir();
write_markdown_assemble_target_lesson(
&tmp,
&[("intro", "# 规则一\n- 范围"), ("rule2", "# 规则二\n$8\\times$")],
&[
("intro", "# 规则一\n- 范围"),
("rule2", "# 规则二\n$8\\times$"),
],
);
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
@@ -667,6 +670,6 @@ fn tempdir() -> PathBuf {
p
}
fn cleanup(p: &PathBuf) {
fn cleanup(p: &Path) {
let _ = std::fs::remove_dir_all(p);
}
+41 -16
View File
@@ -51,12 +51,12 @@ enum Command {
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
Completions {
/// Which shell to generate completions for.
shell: Shell,
shell: CompletionTarget,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum Shell {
enum CompletionTarget {
Bash,
Zsh,
Fish,
@@ -82,16 +82,16 @@ fn main() -> ExitCode {
/// Emit a shell-completion script for `shell` to stdout. The script is built
/// from the same `Cli` clap definition above, so it tracks subcommands/flags as
/// they evolve.
fn run_completions(shell: Shell) -> ExitCode {
fn run_completions(shell: CompletionTarget) -> ExitCode {
use clap::CommandFactory;
use clap_complete::Shell as CompShell;
let sh = match shell {
Shell::Bash => CompShell::Bash,
Shell::Zsh => CompShell::Zsh,
Shell::Fish => CompShell::Fish,
Shell::PowerShell => CompShell::PowerShell,
Shell::Elvish => CompShell::Elvish,
CompletionTarget::Bash => CompShell::Bash,
CompletionTarget::Zsh => CompShell::Zsh,
CompletionTarget::Fish => CompShell::Fish,
CompletionTarget::PowerShell => CompShell::PowerShell,
CompletionTarget::Elvish => CompShell::Elvish,
};
let mut cmd = Cli::command();
clap_complete::generate(sh, &mut cmd, "cph", &mut std::io::stdout());
@@ -188,7 +188,10 @@ fn run_build(
/// explicit `cph build --target <name>`, and each command is printed before it
/// runs so the user sees exactly what is executed.
fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("running shell-step target '{target}' (commands execute in {})", path.display());
eprintln!(
"running shell-step target '{target}' (commands execute in {})",
path.display()
);
let report = cph_check::run_shell_target(path, engine, target);
print_diagnostics(&report.check);
@@ -196,7 +199,10 @@ fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> Exi
// Refused before running anything (check errors / unknown target / no
// shell steps): the diagnostics above explain why.
if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count());
eprintln!(
"build refused: {} errors (fix the lesson first)",
report.check.error_count()
);
} else {
eprintln!("build failed: target '{target}' has no shell steps to run");
}
@@ -212,13 +218,19 @@ fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> Exi
eprint!("{}", outcome.stderr);
eprintln!(
"step failed (exit {})",
outcome.status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
outcome
.status
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into())
);
}
}
if report.ok {
println!("shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len());
println!(
"shell-step target '{target}' completed: {} step(s) ok",
report.outcomes.len()
);
ExitCode::SUCCESS
} else {
eprintln!("shell-step target '{target}' failed");
@@ -231,13 +243,19 @@ fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> Exi
/// shell path, this is opt-in — only on an explicit `cph build --target <name>`,
/// and never in `check`.
fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("assembling markdown target '{target}' (reading parts under {})", path.display());
eprintln!(
"assembling markdown target '{target}' (reading parts under {})",
path.display()
);
let report = cph_check::run_markdown_assemble_target(path, engine, target);
print_diagnostics(&report.check);
if report.outcomes.is_empty() && !report.ok {
if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count());
eprintln!(
"build refused: {} errors (fix the lesson first)",
report.check.error_count()
);
} else {
eprintln!("build failed: target '{target}' has no assemble-markdown steps to run");
}
@@ -250,7 +268,11 @@ fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target:
outcome.field, outcome.parts_read
);
if let Some(out_rel) = &outcome.written {
println!("wrote {} ({} bytes)", path.join(out_rel).display(), outcome.body.len());
println!(
"wrote {} ({} bytes)",
path.join(out_rel).display(),
outcome.body.len()
);
}
if let Some(err) = &outcome.error {
eprintln!("assemble failed: {err}");
@@ -258,7 +280,10 @@ fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target:
}
if report.ok {
println!("assemble-markdown target '{target}' completed: {} step(s) ok", report.outcomes.len());
println!(
"assemble-markdown target '{target}' completed: {} step(s) ok",
report.outcomes.len()
);
ExitCode::SUCCESS
} else {
eprintln!("assemble-markdown target '{target}' failed");
+3
View File
@@ -10,3 +10,6 @@ serde = { workspace = true }
# `preserve_order` keeps `[targets.*]` tables in TOML document order, which is
# the declared order this crate exposes via `Lesson.targets` (ADR-0009).
toml = { workspace = true, features = ["preserve_order"] }
[dev-dependencies]
tempfile = "3"
+5 -1
View File
@@ -737,7 +737,11 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -
/// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that
/// explicitly renders no kind (every used kind then draws a `renderIgnored`
/// warning) — distinct from an absent key.
fn parse_covers(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Option<Vec<String>> {
fn parse_covers(
name: &str,
value: toml::Value,
diags: &mut Vec<Diagnostic>,
) -> Option<Vec<String>> {
let items = match value {
toml::Value::Array(items) => items,
_ => {
+19 -20
View File
@@ -279,16 +279,15 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() {
/// match exactly (ADR-0016's MVP rule).
const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Write a one-part lesson into a temp dir, optionally with a `.cph-version`
/// file. Returns the temp dir so the caller runs `load`.
fn tmp_lesson_with_version(version: Option<&str>) -> PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
p.push(format!("cph-version-test-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
/// Write a one-part lesson into a uniquely-created temp dir, optionally with a
/// `.cph-version` file. `TempDir` owns cleanup so parallel tests cannot delete
/// one another's fixture after a timestamp collision.
fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
let tmp = tempfile::Builder::new()
.prefix("cph-version-test-")
.tempdir()
.unwrap();
let p = tmp.path();
std::fs::write(
p.join("manifest.toml"),
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n",
@@ -301,14 +300,13 @@ fn tmp_lesson_with_version(version: Option<&str>) -> PathBuf {
if let Some(v) = version {
std::fs::write(p.join(".cph-version"), v).unwrap();
}
p
tmp
}
#[test]
fn cph_version_matching_is_not_a_diagnostic() {
let tmp = tmp_lesson_with_version(Some(CPH_VERSION));
let (_lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
let (_lesson, diags) = load(tmp.path());
assert!(
diags.iter().all(|d| d.code != DiagCode::CphVersionMismatch),
"a matching .cph-version must not warn, got {diags:?}"
@@ -318,8 +316,7 @@ fn cph_version_matching_is_not_a_diagnostic() {
#[test]
fn cph_version_mismatch_is_an_error_diagnostic() {
let tmp = tmp_lesson_with_version(Some("99.99.99"));
let (lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
let (lesson, diags) = load(tmp.path());
// The lesson still loads (so other defects could surface), but the
// mismatch is an error diagnostic — which alone makes it illegal.
assert!(lesson.is_some(), "a mismatch should not halt loading");
@@ -327,7 +324,11 @@ fn cph_version_mismatch_is_an_error_diagnostic() {
.iter()
.filter(|d| d.code == DiagCode::CphVersionMismatch)
.collect();
assert_eq!(mm.len(), 1, "expected one CphVersionMismatch, got {diags:?}");
assert_eq!(
mm.len(),
1,
"expected one CphVersionMismatch, got {diags:?}"
);
assert!(
mm[0].message.contains("99.99.99") && mm[0].message.contains(CPH_VERSION),
"diagnostic should name both versions, got: {}",
@@ -339,8 +340,7 @@ fn cph_version_mismatch_is_an_error_diagnostic() {
fn cph_version_missing_is_skipped() {
// No `.cph-version` file at all → skipped (ADR-0016 migration period; OPEN).
let tmp = tmp_lesson_with_version(None);
let (_lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
let (_lesson, diags) = load(tmp.path());
assert!(
diags.iter().all(|d| d.code != DiagCode::CphVersionMismatch),
"a missing .cph-version must be skipped, got {diags:?}"
@@ -350,8 +350,7 @@ fn cph_version_missing_is_skipped() {
#[test]
fn cph_version_empty_is_an_error() {
let tmp = tmp_lesson_with_version(Some(" \n"));
let (_lesson, diags) = load(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
let (_lesson, diags) = load(tmp.path());
assert!(
diags
.iter()
+5 -1
View File
@@ -526,7 +526,11 @@ mod tests {
// ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`),
// distinct from the `.typ` content fields.
let s = schema_for("segment").unwrap();
let slides = s.content_fields.iter().find(|f| f.name == "slides").unwrap();
let slides = s
.content_fields
.iter()
.find(|f| f.name == "slides")
.unwrap();
assert_eq!(slides.ext, "md");
assert!(!slides.required);
let transcript = s
+2 -1
View File
@@ -48,7 +48,8 @@ pub fn resolve_render_dir() -> PathBuf {
ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes.
let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
let fallback =
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
let _ = extract_to(&fallback);
fallback
})
@@ -26,4 +26,6 @@ The group can stay open while no Claude processing is active. A teacher leaving
- Multi-open is natural: a teacher participates in multiple project groups.
- Normal group discussion does not occupy the project.
- "Exit project" should not be the normal action in the group workflow.
- Project rebinding or group archival needs explicit product rules, separate from run cancellation.
- ADR-0021 adds those explicit product rules: active project↔Feishu chat binding
is strict 1:1, while archived historical bindings are retained so org admins
can correct pilot setup mistakes without losing audit/session history.
@@ -1,4 +1,4 @@
# ADR 0017: AgentSession Is Provider-Bound
# ADR 0017: Agent Runtime — Claude Code SDK via OpenRouter
## Status
@@ -6,45 +6,62 @@ Accepted.
## Context
ADR-0002 says a long-lived `AgentSession` can be reused by many runs, but never
addressed the provider/model dimension. The agent layer is provider-agnostic
(ADR-0001..0003 never required Claude specifically; the `@Claude` trigger name
is a product brand, not a provider commitment). In practice the Hub routes
model calls through an OpenAI-compatible client (e.g. OpenRouter), so a run may
target different models — and the motivation for multi-model is **role-based
routing**: drafting, reviewing, and triage suit different models.
The original ADR-0017 mandated a provider-agnostic agent layer: any
OpenAI-compatible model via OpenRouter, custom agent loop built on Vercel AI
SDK's `streamText`. The motivation was to avoid vendor lock-in.
That raises the question ADR-0002 left open: when a teacher switches model
mid-project, does the `AgentSession` carry live context across the switch, or
is it bound to a single provider/model?
In practice, the hand-rolled agent loop lacked capabilities that a production
agent needs: context compaction (long conversations), tool approval flows,
partial-message streaming, and a battle-tested multi-turn loop. Building these
ourselves meant re-implementing what the Claude Code SDK already provides.
The Feishu integration also proved costly to hand-roll — card schema quirks,
patch limitations, file support — each API quirk cost a round-trip.
## Decision
An `AgentSession` is **provider/model-bound**. A model or provider switch
starts a new `AgentSession`. The `AgentSession` does not carry live context
across a switch.
Adopt `@anthropic-ai/claude-agent-sdk` as the agent runtime, routed through
OpenRouter's **"Anthropic Skin"** (`https://openrouter.ai/api`).
Cross-session continuity is not the session's job — it is carried by ADR-0003's
project memory and anchors, which the Hub reads on demand when seeding a new
run. This keeps B consistent with ADR-0003 ("the Hub avoids becoming a full
chat history store"): session continuity and history continuity are the same
mechanism, both via memory/anchors, never via a live cross-provider session.
OpenRouter exposes an Anthropic Messages API-compatible endpoint. Claude Code
SDK speaks its native protocol directly to OpenRouter — no proxy, no format
conversion. The SDK's `query()` owns the agent loop (tool dispatch, compaction,
streaming). Built-in tools (Read, Write, Bash, Glob, Grep) cover the entire
tool surface — no custom tools needed. `cph check` / `cph build` run via Bash.
Per-run model selection (the run carries its model) is the switching point.
The Hub `AgentSession` is provider/role/model-bound, and it must persist the
provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as
`options.resume`. Role is part of the session binding because role prompts and
tool surfaces can differ even when the underlying model is the same; `/draft`
and `/review` must not resume the same Claude runtime cursor by accident.
Environment variables:
```
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=<OpenRouter API key>
ANTHROPIC_API_KEY="" # must be explicitly empty
```
Model routing: `ANTHROPIC_DEFAULT_SONNET_MODEL` etc. accept OpenRouter model
IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This gives
limited OpenRouter-level model routing, but the runtime is still Claude Agent
SDK-shaped: non-Claude models are best-effort and may not support every
Claude Code feature.
## Consequences
- Switching model mid-project = new session; the new run seeds from project
memory/anchors (ADR-0003), not the prior session's live context.
- The agent layer is provider-agnostic: model calls go through an
OpenAI-compatible client, and the tool-calling loop is delegated to the
Vercel AI SDK (`generateText` + `tool`). The provider seam is the SDK's
`LanguageModel` interface, not a hand-rolled loop; swapping provider is
swapping the `createOpenAICompatible` factory, not rewriting the loop. No
hard dependency on a single-vendor agent SDK.
- Role-based model routing (different run kinds default to different models) is
a product/admin configuration concern, not a spec invariant.
- "AgentSession reused by many runs" (ADR-0002) holds *within* one
provider/model; it does not span a switch.
- The `@Claude` trigger name remains the product mention brand regardless of the
backing model.
- Provider-agnosticism is not fully preserved. OpenRouter can route to GLM,
Claude, GPT, etc., but the runtime protocol and agent loop remain Claude
Agent SDK-bound.
- The agent loop is Claude Code SDK's (compaction, tool approval, partial
message streaming) — not hand-rolled.
- Custom tools (read_file, write_file, cph_check, cph_build) are replaced by
the SDK's built-in Read/Write/Bash/Glob/Grep.
- The SSE event format matches Claude-to-IM's expected protocol natively.
- Per-run model selection works via role→model mapping; models are OpenRouter
IDs, not Anthropic-only aliases.
- OpenRouter recommends setting Anthropic as the top-priority provider for
Claude Code; non-Anthropic models may have reduced compatibility with some
SDK features (e.g. thinking blocks).
@@ -0,0 +1,201 @@
# ADR 0018: The Agent Execution Surface Is Bounded By The Project Workspace
## Status
Accepted, and implemented. The mechanism is settled: the Claude Code SDK's
built-in sandbox (bubblewrap on Linux, seatbelt on macOS) confines the agent
process and its Bash subprocesses at the OS level. `bypassPermissions`
remains (headless server — no interactive prompts), but the sandbox is the
hard boundary, not the permission-prompt layer. Constitution rule 4
divergence (ADR-0017's unbounded tools) is resolved.
Builds on **ADR-0001** (project group as collaboration space), **ADR-0002**
(project lock scoped to `run_id`), **ADR-0004** (permission grants govern
`triggerAgent`), **ADR-0007** (the engineering file is a directory tree on
disk), and **ADR-0017** (Claude Code SDK as the agent runtime).
## Context
ADR-0001/0002/0004 together cover *collaboration governance* up to the
moment a teacher `@Claude`s: who may trigger an `AgentRun`, that the run
holds a project-scoped lock, that the group is a long-lived space. They stop
at `triggerAgent`. What the agent may do *after* the trigger — which files it
may read or write, what commands it may run — is not named in any ADR, not
modeled in `spec/`, and not enforced by any contract-level invariant.
The gap is not theoretical. ADR-0017 adopted the Claude Code SDK with
`allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"]` and
`permissionMode: "bypassPermissions"`. Under that configuration the SDK's
built-in tools operate over the whole host filesystem with no confinement, and
`Bash` executes arbitrary shell with no approval gate. The Hub's
`hub/src/agent/workspace.ts` was *intended* to be "the agent's only file
surface" (its module doc says so) and ships a `confine()` path validator that
rejects `..` and absolute escapes. But ADR-0017's cutover left those custom
tools unwired — the SDK's built-in tools replaced them — so `confine()` is
dead code and the boundary it was meant to enforce does not exist at runtime.
Compounding this, the systemd unit's default `SERVICE_USER` is `root`
(`hub/deploy/install_service.sh`), so the unbounded agent runs as root.
This is a real divergence point by the constitution's test: "if unwritten,
will the developer and the agent make different assumptions?" They already
have. ADR-0017 assumed the boundary was an implementation concern; the
`workspace.ts` author assumed it was a contract. The next person to touch the
runner has nothing telling them the boundary must be enforced, and ADR-0017
silently overrode the one that existed.
## Decision
### The agent execution surface is bounded by the run's project workspace
During an `AgentRun`, every file operation the agent performs — read, write,
glob, grep target — must fall **within the run's project workspace directory**
(the on-disk engineering-file tree of ADR-0007). Operations whose target path
escapes that tree are refused. This is a platform core invariant, pinned in
`Spec.System.AgentSurface`.
The boundary is **per-run, anchored to the run's project**, the same scope as
the Lock (ADR-0002). This keeps the two invariants composable:
- `Lock` (ADR-0002) bounds **concurrency** — who may be modifying the project.
- `AgentSurface` (this ADR) bounds **blast radius** — what the agent may touch.
Both scope to `run × project`. Acquiring the lock and entering the surface
happen together at run start; releasing the lock and leaving the surface
happen together at run termination.
### Shell execution is subject to the same boundary, by effect
A shell command the agent runs executes with the workspace as its working
directory (already true for the `cph` subprocess, ADR-0016) and its **file
effects** — files read, written, created, or deleted — are subject to the same
workspace boundary. The agent may invoke **named external tools** whose
binaries live outside the workspace (notably `cph` via `CPH_BIN`); the
boundary governs *file effects*, not *binary location*. Arbitrary
`cat /etc/shadow` or `curl … > /etc/...` is out of bounds regardless of how it
is invoked.
### The Hub process runs as a non-privileged service user
The systemd unit must not run as `root`. The default in
`hub/deploy/install_service.sh` changes from `root` to a dedicated unprivileged
user. This is a deployment invariant, recorded here because the unbounded-agent
risk is compounded by root; it is enforced in the unit, not in `spec/` (it is
not a semantic divergence between developer and agent — it is operational
discipline).
### The mechanism: SDK sandbox (OS-level, per agent process)
The boundary is enforced by the Claude Code SDK's built-in sandbox
(bubblewrap plus `socat` on Linux, seatbelt on macOS), configured via `query()` options in
`hub/src/agent/runner.ts`:
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed.
- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the
SDK's `dangerouslyDisableSandbox` input. Claude Code 2.1.202 does not enforce
that option reliably, so a host-side `PreToolUse` hook also denies every
Bash request whose input explicitly sets `dangerouslyDisableSandbox: true`
before a process can start.
- `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
current workspace and a small named system-runtime set — normal reads stay
in the run's workspace while `/bin`, shared libraries, CA certificates,
fonts and the configured `cph` executable remain available as the external
tool exception described above.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines persistent host
effects to the canonical ADR-0007 workspace. Config/cache/home stay beneath
`.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
point at the workspace-local `.cph/t`. The workspace allocator uses stable
compact Organization/Project path segments, deployment requires a short
workspace root, and the canonical temp prefix fails fast above 56 bytes so
the SDK can append randomized `socat` bridge socket names without exceeding
Linux `sockaddr_un.sun_path`. Bubblewrap shadows non-allowlisted host trees
with disposable tmpfs mounts: a shell write there may succeed inside that
private namespace, but it cannot mutate the corresponding host path. The
Linux proof checks host state after the sandbox exits.
- The SDK subprocess environment replaces rather than spreads `process.env`.
Only provider protocol variables and non-secret runtime variables cross the
boundary; database, Feishu and Hub session credentials never enter it.
`sandbox.credentials` denies provider token variables inside Bash and denies
configured credential files. `CPH_SANDBOX_EXTRA_DENY_READ` names additional
deployment-specific secret paths on the Hub side only.
- `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths.
- Platform-curated Agent skills are immutable Hub release assets, loaded as a
programmatic local plugin from a release-owned path. The sandbox exposes that
path read-only, and the SDK receives only plugin-qualified allowlist names
through its `skills` option. Filesystem setting sources remain disabled, so a
project cannot register another skill or widen its tools through `.claude`
settings. Requested skill ids are recorded on `run.created`; SDK
initialization/results remain the authoritative evidence that loading
actually succeeded.
- Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the
sandbox is the hard boundary, not the permission-prompt layer. This is
subprocess-level, not process-level: the Hub host process is not sandboxed
(it manages DB, Feishu ws, logs), only the Claude Code agent process and its
children. This keeps the sandbox config surface small and lets future
external CLIs (Gitea, Lark) run inside the sandbox by giving their binaries
read access, without widening the Hub's own filesystem surface.
The contract pins the **invariant** (operations stay in the workspace), not
the **mechanism**. The SDK sandbox is the current mechanism; switching to a
different one (tool wrappers, container) would not be a spec change as long
as it upholds `AgentFileOp.Authorized`.
## Consequences
- `Spec.System.AgentSurface` gains an `AgentFileOp` structure and an
`Authorized` predicate mirroring `Memory.McpReadRequest.Authorized`: the op
carries a `run` and a `path`; `Authorized` holds iff the path falls within
the run's workspace, via platform-provided `runWorkspace` and `pathWithin`
(representations `OPEN`).
- `hub/src/agent/workspace.ts`'s `confine()` intent is now contract-backed.
Re-wiring it (or replacing it with an OS sandbox) is **mandatory**, not a
preference. Its current dead-code status under ADR-0017 is a divergence to
be addressed, not silently retained.
- ADR-0017's `bypassPermissions` + unrestricted built-in tools is a
**divergence from this ADR** and is surfaced as such (constitution rule 4).
The reconciliation — restrict the SDK tool set, wrap Bash, or sandbox at the
OS level — is the open mechanism question above. Whichever is chosen must
uphold the `AgentSurface` invariant.
- Inbound file/image delivery (`hub/src/feishu/trigger.ts`) lands files under
`${workspaceDir}/.cph/inbox/`, which is inside the boundary — consistent, no
change to the delivery path.
- The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir`
(`hub/src/agent/cph.ts`), so it is inside the boundary by construction.
## Open Questions / Deferred
- **Network egress.** Decided: network is **open** in the initial
production deployment. The sandbox confines filesystem writes to the
workspace and denies reads of sensitive host paths, but does not restrict
outbound network. Rationale: future Gitea/Lark CLI integration needs
unconstrained network egress; `cph build` may fetch typst packages. The
exfiltration risk (agent `curl`s workspace content out) is accepted as a
tradeoff for CLI integration simplicity. This is revisitable — a network
allowlist (`sandbox.network.allowedDomains`) can be added without a spec
change if the threat model tightens.
- **Named system-runtime read set.** The implementation allows only the
platform runtime directories/files needed for shell, dynamic libraries,
fonts, TLS/DNS and `cph` execution in addition to the project workspace.
Expanding that set requires an explicit reviewed code change. Deployments
name credential files with `CPH_SANDBOX_EXTRA_DENY_READ`; these paths are
protected through the SDK credential layer and are not passed to the Agent
environment. The exact runtime set remains plumbing and is not spec-pinned.
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
*effects* are then confined, is a policy choice left to implementation. The
invariant governs effects either way; current choice is arbitrary commands
(sandbox confines effects).
## Related Decisions
- **Rate limiting and token usage.** ADR-0022 requires multi-dimensional hard
request limits while keeping token thresholds as attributed soft alerts in
the initial release.
- **Inbound file limits.** ADR-0022 requires hard file, attachment, archive,
Project-storage, and Organization-storage limits with explicit failure and
partial-file cleanup.
@@ -0,0 +1,129 @@
# ADR 0019: Resolve Actors to Principal Sets for Team Permissions
## Status
Accepted.
## Context
ADR-0004 deliberately chose a Feishu Docs-like permission model:
`grant(resource, principal, role)` plus separate resource settings. The first
Hub implementation stored `principal` as an opaque string and the Feishu trigger
used the sender's `open_id` directly. That was enough for individual teacher
grants, but not for production collaboration:
- a curriculum team needs one grant to apply to many teachers;
- Feishu departments, user groups, and project chats should be grantable
principals;
- role-specific agent grants (`/review`, `/draft`) must compose with project
permissions consistently;
- audit/debug output must explain which principal and grant allowed or denied a
run.
## Decision
Introduce typed principals and resolve every actor to a principal set before
authorization.
### Principal types
`PrincipalType` is:
- `USER`: a Feishu user, identified by `feishuOpenId`.
- `TEAM`: a Hub-managed teacher team.
- `FEISHU_CHAT`: a Feishu chat/group.
- `FEISHU_DEPARTMENT`: a Feishu department.
- `FEISHU_USER_GROUP`: a Feishu user group.
- `APP`: an integration or bot principal.
`PermissionGrant` and `RoleTriggerGrant` store `principalType` and
`principalId`. Legacy opaque principal strings are backfilled as
`USER/<old principal>`.
### Team membership
Hub teams are first-class. A user can be a direct active member of a team. A
team can also be bound to external Feishu principals: chat, department, or user
group. If an actor resolves to any bound external principal, the actor also
resolves to that Hub team.
Membership changes are effective immediately because authorization resolves the
principal set at request time.
### External Feishu principal sync
Feishu department, user group, and chat memberships are not scattered through
callers. They are synchronized into `ExternalPrincipalMembership` rows and then
read by `PrincipalResolver`. The Feishu message context can also contribute the
current `FEISHU_CHAT` principal directly because receiving a group event is
already contextual proof that the actor is speaking in that group.
### Grant merging
Authorization evaluates all active grants matching any resolved principal for
the requested resource. There is no explicit deny in this ADR. Among matching
grants, the highest role wins:
```text
READ < EDIT < MANAGE
```
Action thresholds:
- project read requires `READ`.
- project edit requires `EDIT`.
- collaborator management requires `MANAGE`.
- agent trigger requires `EDIT`.
- normal agent cancel requires `MANAGE`.
`PermissionSettings` never grants access beyond grants. Settings only constrain
an already-granted capability. For `agentTrigger`:
- missing setting or `ROLE` means role-derived grants decide;
- `MANAGE_ONLY` raises the threshold to `MANAGE`;
- `DISABLED` denies the action.
Other settings keep their existing string storage, but the same rule applies:
settings are policy constraints, not positive grants.
### Role-trigger composition
Role-trigger grants are a second gate after the project-level `agent.trigger`
gate:
1. The actor must pass project `agent.trigger` for the project.
2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`,
the role is open for backward compatibility.
3. Once any row exists for `(projectId, roleId)`, including a revoked row, the
role is configured. The actor must match an active role grant by any resolved
principal.
This keeps `/review` restrictions orthogonal to ordinary edit access while
preventing a fully revoked role from silently reopening.
### Module seam
Callers use a single authorization module:
```ts
authorizer.can({
actor,
action,
resource,
roleId,
})
```
The caller does not know how users, teams, Feishu departments, user groups, or
chats expand. `PrincipalResolver` owns that implementation, and
`PermissionAuthorizer` owns grant/settings/role-trigger composition.
## Consequences
- Teacher teams become production-grade principals rather than UI sugar.
- Feishu external organization concepts can be synchronized and granted without
leaking Feishu-specific checks into the trigger path.
- A single audit decision can report actor, principal set, matched grant, and
matched role grant.
- Backward compatibility is explicit: old principal strings become user
principals, and unconfigured role grants remain open.
@@ -0,0 +1,94 @@
# ADR 0020: Organization Is The SaaS Tenant Root
## Status
Accepted.
## Context
The Hub is moving from a single-organization deployment assumption toward a
SaaS shape. The existing permission model already supports team principals via
`PermissionGrant(PROJECT, TEAM, role)`, but without a tenant root the data model
cannot safely answer:
- which customer owns a project;
- which organization a team belongs to;
- whether a team grant crosses customer boundaries;
- which external directory sync produced a Feishu department/user-group
membership;
- where future org admin, billing, audit, and platform break-glass controls
attach.
Retrofitting this after projects, teams, runs, and audit records grow would
force tenant assumptions into every caller.
## Decision
Introduce `Organization` as the SaaS tenant root.
Customer-side collaboration data is organization-scoped:
```text
Organization
OrganizationMembership
Team
TeamMembership
Project
ExternalDirectoryConnection
PermissionGrant
```
`User` remains global. A user can belong to multiple organizations through
`OrganizationMembership`. Project-level authorization still uses
`PermissionGrant` and `PermissionAuthorizer`; the authorizer derives the
organization from the requested resource, then resolves teams and external
principals in that organization scope.
`Team` belongs to exactly one organization. `Project` belongs to exactly one
organization. A `PermissionGrant` that grants a `TEAM` principal on a `PROJECT`
resource is valid only when the team and project are in the same organization.
This invariant is pinned in `Spec.System.Organization`.
External directory membership is scoped through `ExternalDirectoryConnection`:
```text
ExternalDirectoryConnection(
organizationId,
provider,
providerTenantId,
source
)
```
`ExternalPrincipalMembership` points at a connection rather than carrying a
free-floating `source` string. `source` names a sync pipeline; it is not the
tenant identity.
The SaaS platform control plane is separate from customer project permissions.
`READ/EDIT/MANAGE` remains the customer-side project permission lattice.
Platform staff access, tenant suspension, billing, and break-glass access are a
separate control plane and must not be modeled as project `MANAGE`.
## Consequences
- Existing single-org data is backfilled into a default organization during
migration.
- `Team.slug` becomes organization-scoped instead of globally unique.
- Actor resolution is organization-aware: direct teams and synchronized external
principals only expand inside the resource's organization.
- Direct `USER` grants remain possible because a global user can collaborate
across organizations when explicitly granted.
- Future org admin UI can be built on this model without changing the
authorization core.
## Open Questions / Deferred
- Full platform admin UI, billing, and tenant suspension workflows are
deferred. Production safety limits and workload brakes are no longer
deferred; ADR-0022 defines them without treating a workload brake as tenant
suspension.
- Whether direct `USER` project grants should require active
`OrganizationMembership` is deferred; explicit direct grants remain allowed
for now.
- Provider-specific directory semantics beyond Feishu are deferred, but the
connection model leaves room for them.
@@ -0,0 +1,117 @@
# ADR 0021: Org Admin Project Onboarding
## Status
Accepted.
## Context
ADR-0020 introduced `Organization` as the SaaS tenant root. The next product
surface is not only the Feishu bot trigger path, but also the control planes
around it:
- platform staff need a private platform admin area to create and operate orgs;
- customer org owners/admins need an org admin area for teams, roles, provider
mode and BYOK configuration, projects, folders, sessions, and usage accounting;
- ordinary teachers should be able to start work from the natural Feishu group
flow without entering the web backend.
Without a crisp project onboarding model, project creation, Feishu chat binding,
permissions, session history, and usage accounting would drift into separate
ad-hoc rules.
## Decision
Use one web app with two separate admin areas:
```text
/admin/platform internal platform admins only
/admin/org/:orgSlug customer org OWNER/ADMIN only
```
The guards are intentionally separate:
- `requirePlatformAdmin` for internal operators;
- `requireOrgRole` for org owner/admin backend access;
- `requireProjectPermission` for project-level actions.
Platform admin is not an `OrganizationMembership` and is not represented by
project `PermissionGrant`. Platform admin audit remains separate from customer
project audit.
Customer orgs are manually created by platform staff for the pilot. Platform
admins invite/allowlist other platform admins. Customer users authenticate with
the customer's Feishu app. The same customer-owned Feishu app may be used for
OAuth login, bot messages, and directory sync. App secrets are org-scoped
secrets and must be encrypted at rest; business code should access them through
a secret/connection resolver, not raw plaintext columns.
Each Organization chooses one model-provider credential mode:
- `BYOK`: an Organization OWNER/ADMIN supplies and manages that Organization's
provider key and base URL in the org admin area;
- `PLATFORM_MANAGED`: a platform admin supplies and manages one distinct key
and base URL for that Organization in the platform admin area. Organization
users cannot read the plaintext credential.
Provider connections remain Organization-scoped in both modes. Unrelated
Organizations never share a process-global provider key. Provider secrets must
be encrypted at rest and resolved at runtime through the same connection/secret
boundary as Feishu credentials.
Project management has two creation paths:
- org owner/admin creates projects in the org web backend;
- ordinary org members may create a project from an unbound Feishu group when
`membersCanCreateProjects` is enabled for the org.
In both paths the creator gets project `MANAGE`. A Feishu-created project is
immediately bound to the source chat, and that chat receives project `EDIT`.
Feishu chat binding is strict 1:1:
- one Feishu chat binds to at most one active project;
- one project binds to at most one Feishu chat;
- binding an existing unbound project from Feishu requires the clicking user to
have `MANAGE` on that project;
- binding creates an active `FEISHU_CHAT -> PROJECT EDIT` grant;
- binding mistakes are corrected by org owner/admin unbinding or archiving the
binding in the backend; historical sessions stay on their original project.
Folders are transparent organization nodes for project navigation and usage
aggregation:
- folders belong to one organization;
- projects may sit in folders;
- folders can be nested;
- folders are not permission resources;
- folder visibility, team policies, and inherited grants are deferred.
Project permissions remain on `PROJECT` resources. Moving a project between
folders does not change grants.
Usage accounting is org-wise and project/folder aggregatable for both provider
modes. It is not payment collection in the pilot: BYOK spend belongs to the
customer's provider account, while platform-managed spend is attributed to the
Organization for operational reporting; commercial billing remains deferred.
## Consequences
- `OrganizationProjectSettings.membersCanCreateProjects` gates Feishu group
project creation for ordinary members.
- `Folder` and `Project.folderId` support the file-manager-like project
explorer without creating a second ACL system.
- Service code should expose project creation and chat binding as reusable
backend operations so Feishu cards and future web APIs call the same rules.
- Org role/model/provider-mode panels and the platform-managed connection panel
can be added on top of the org tenant root without changing project authorization.
## Open Questions / Deferred
- True one-click Feishu app provisioning is deferred; pilot uses guided setup
and readiness checks.
- Folder-level permissions are deferred until there is a concrete customer need.
- Self-serve org signup and payment collection are deferred beyond pilot.
- The platform admin identity, session, bootstrap, invitation, audit, and
offline recovery boundary is decided by ADR-0023 and must be implemented
before exposing the platform admin panel.
@@ -0,0 +1,88 @@
# ADR 0022: Layered Capacity Admission And Workload Safety
## Status
Accepted.
## Context
The initial SaaS deployment shares one production host across Organizations.
The current Hub only has a one-run-per-project lock, a process-local five-item
trigger queue, a five-minute queue wait, a global max-turn setting, and
Fastify's default request-body limit. It has no global or Organization agent
capacity, durable backpressure, fair scheduling, storage quotas, run wall-clock
budget, multi-dimensional rate limiting, or emergency workload control.
Leaving these limits implicit lets one Organization monopolize the host, lets
accepted work disappear on restart, and makes overload look like success. A
single global FIFO would preserve arrival order while still permitting one
tenant to occupy every execution slot. Unlimited queues or automatic whole-run
replay would hide overload and can duplicate tool side effects.
## Decision
Every mandatory capacity dimension has a non-optional **Platform Capacity
Ceiling**. An Organization OWNER/ADMIN may configure a lower **Organization
Policy Limit**; the **Effective Limit** is the lower value, or the platform
ceiling when the Organization value is absent. There is no unlimited fallback.
Production refuses to start when a mandatory platform ceiling is absent or
invalid. Platform ceilings are changed through versioned deployment
configuration in the initial release; live platform-admin editing is deferred
until audited approval and rollback exist.
Agent work uses a durable, bounded admission queue. Scheduling is fair across
Organizations and FIFO within an Organization, while the existing one-active-
run-per-Project rule remains. Capacity saturation queues only within effective
length and wait limits. Queue overflow is an explicit `capacity_exhausted`
response with retry guidance; an accepted request that waits too long becomes
`EXPIRED`, is never started later, and notifies its initiator. Initiators may
withdraw their own queued requests; Project `MANAGE` and Organization admins
may cancel queued requests in their scope. Every transition is auditable.
The platform never automatically replays a whole Agent Run after execution has
started. Limited transport retries are allowed only before model output or tool
side effects. Otherwise the run ends explicitly and a retry is a new user-
initiated request. Feishu event identity and API idempotency prevent delivery
retries from creating duplicate admissions.
The first production gate requires hard limits for request rate/body size,
agent concurrency, queue length/wait, files and attachment counts, archive
expansion, Project and Organization storage, Organization entity counts, run
wall time/turns/tool calls/output, individual tool duration, and execution
process memory/CPU/process count. Reaching an entity or storage ceiling blocks
new growth but does not delete, hide, or lock existing data; read, export,
delete, and administrator remediation remain available.
Admin surfaces require an authenticated user plus the corresponding platform
or Organization administrator authorization. Authenticated Web traffic is
limited by user, Organization, and endpoint class; signed Feishu traffic is
limited by Organization application, chat, and sender; administrative writes
use separate stricter buckets. Rejections and truncation are never silent.
Token, provider-reported cost, run count, and duration are attributed by
Organization, Project, Run, model, and Provider Connection. They are statistics
and soft alerts in the initial release, not hard spend controls or settlement.
Missing provider cost remains unknown rather than zero. Organization admins
receive their alerts; platform admins additionally receive anomalous-cost
alerts for platform-managed Provider Connections, not BYOK financial alerts.
Platform administrators have audited, reason-bearing, reversible workload
brakes at Organization and platform scope. `DRAIN` rejects new work and pauses
queue starts while allowing active runs to finish. `STOP_NOW` also cancels
active runs. These controls do not delete or suspend the Organization.
Exact numerical ceilings remain open until production-like capacity tests
measure the supported host and provider behavior. Defaults must be checked in,
validated, observable, and proven at the release gate rather than guessed as
domain constants.
## Consequences
- The current in-memory `TriggerQueue` and global-only agent settings are
implementation divergences, not acceptable production fallbacks.
- Durable ingress, run/lock recovery, authorization, telemetry, and capacity
enforcement must share one Organization-scoped admission identity.
- HTTP overload uses typed `429` responses with `Retry-After`; Feishu reports a
clear busy/expired/canceled outcome to the initiating user.
- Capacity calibration is a release prerequisite and must include noisy-neighbor,
restart, queue expiry, file/archive, provider-stall, and workload-brake tests.
@@ -0,0 +1,136 @@
# ADR 0023: Platform Administrator Identity And Audit Boundary
## Status
Accepted.
## Context
ADR-0021 separated `/admin/platform` from Organization administration but left
the exact platform identity store and audit schema open. That choice must be
settled before exposing the panel: customer Feishu identities are scoped by
their customer-owned applications, Organization membership must not confer
platform authority, and a platform operator can create tenants, change
connections, rotate credentials, or stop workloads across tenant boundaries.
The current implementation is not the accepted platform model.
`PlatformRoleAssignment` points at the customer-side `User`, includes a legacy
`TEACHER` value, and has no platform login or guard. The current browser session
is a seven-day stateless customer cookie, while `AuditEntry` is Project/Run
oriented and deliberately best-effort. Reusing any of these would couple the
two control planes and make immediate revocation or fail-closed privileged
audit impossible.
## Decision
### Identity issuer and authority
Platform Administrators authenticate only through one dedicated
**Platform-owned Feishu Application**. It is separate from every Organization's
Customer-owned Feishu Application and cannot grant Organization membership or
Project permission.
A `PlatformIdentity` is scoped by that platform application and its verified
external Feishu user identity. It is not a customer `User`. The same person may
also have customer identities, but there is no implicit link, shared session,
or authority propagation between them.
The initial platform control plane has exactly one standing role:
`Platform Administrator`. There is no Platform Owner, Super Admin, or Teacher
hierarchy. The first administrator has no permanent special authority after
another administrator is active.
### Bootstrap, invitation, and revocation
The first Platform Administrator is granted through a checked-in, versioned,
idempotent and transactional bootstrap database procedure. It accepts only a
verified Platform Identity, refuses to run while any standing Platform
Administrator is active, and atomically writes the grant plus its Platform
Audit Entry. Ad-hoc manual `INSERT` statements and permanent environment
allowlists are not supported bootstrap paths.
Later administrators join through short-lived, single-use invitation links
created by an active Platform Administrator. An invitation is bound to one
specific Feishu account in the Platform-owned Feishu Application; possessing a
link without authenticating as that account is insufficient. The invitation
records its inviter and reason and becomes terminal after acceptance, expiry,
or revocation. One active Platform Administrator approval is sufficient for the
initial service.
All standing Platform Administrators are peers. An administrator may invite or
revoke another administrator, but the last active standing administrator cannot
be revoked. Revocation immediately invalidates all Platform Sessions and all
unclaimed invitations belonging to the revoked identity.
### Platform Session and step-up authentication
`Platform Session` is a separate, server-side, revocable session with a distinct
cookie/token namespace. The browser receives an opaque random token; persistence
stores only its hash and binds the session to a Platform Identity. Every request
reloads the session and active grant. The session carries no Organization,
membership, Project, or customer Feishu authority.
Administrator lifecycle changes, platform or customer Feishu connection
changes, platform-managed Provider credential changes, Organization lifecycle
changes, workload-brake changes, and Emergency Platform Grant changes require
recent Feishu OAuth reauthentication. Exact session/idle/step-up durations,
CSRF, cookie and proxy/TLS mechanics belong to the production browser/session
boundary decision; they must be configurable under checked maximums rather than
silently inheriting the current seven-day customer cookie.
### Platform audit
`Platform Audit Entry` is a separate append-only record from customer Project
and Agent Run audit. Every successful platform mutation and its audit entry
commit in the same database transaction. If the audit entry cannot be written,
the mutation fails. Platform audit is durable business evidence, not
best-effort telemetry.
An entry identifies the Platform Identity and Platform Session, action, target
kind and identifier, affected Organization when applicable, mandatory reason
for security-sensitive actions, request/correlation identity, outcome, time,
and redacted before/after facts. It never stores credential, token, recovery-key
or other plaintext secret material. The normal service path can append and read
but cannot update or delete history.
Platform Administrators can read the complete redacted platform audit.
Organization Administrators can read a customer-safe projection of entries
affecting their Organization, including Organization lifecycle, Feishu/provider
connection, and workload-brake changes; they cannot read other Organizations,
platform administrator lifecycle, recovery material, or internal security
events.
### Offline recovery and break-glass
There is no standing break-glass web account or shared emergency password.
Recovery uses a checked-in offline command from a controlled production-host
console. The supported procedure requires both privileged host access and a
recovery key held outside the host, environment file, and ordinary backup set.
It also requires an incident identifier, reason, and one explicit recovery
action.
Recovery may revoke Platform Sessions, repair the Platform-owned Feishu
connection, or issue an `Emergency Platform Grant` to a verified Platform
Identity. An emergency grant is time-bounded, automatically expires, is not a
standing role, and must be explicitly closed when recovery finishes. Every
recovery action is represented in the append-only Platform Audit. Two factors
are mandatory for the initial service; two separate human approvers are not.
## Consequences
- Platform identity, invitation, standing grant, server-side session, platform
audit, and emergency-grant persistence must be distinct from customer
`User`, `OrganizationMembership`, Project permission, and Project/Run audit.
- `requirePlatformAdmin` must resolve the dedicated session and an active
standing or unexpired emergency grant on every request. It is never an
Organization-role override.
- The legacy `PlatformRoleAssignment`/`TEACHER` schema is not a compatible
shortcut and must be migrated or replaced before the platform panel ships.
- Bootstrap and recovery remain direct database/control-plane operations, but
only through versioned, asserted, auditable procedures rather than arbitrary
SQL.
- Exact numeric invitation/session/step-up/emergency durations, audit retention
and export policy, recovery-key custody rotation, and future multi-party
approval remain follow-on operational decisions. They may not weaken the
identity separation, last-administrator protection, fail-closed audit, or
two-factor recovery invariants fixed here.
@@ -0,0 +1,143 @@
# ADR 0024: Local Secret Envelope And Connection Resolution
## Status
Accepted.
## Context
ADR-0021 requires every customer Feishu application and model provider
credential to belong to one Organization. It forbids a process-global provider
credential and requires business code to obtain plaintext only through a
resolver. Before the pilot can store real customer credentials, the deployment
also needs a concrete encryption, rotation, recovery, writer-authority, and
connection-identity contract.
The initial production shape is one Hub service and one PostgreSQL database on
a controlled host. An external KMS would add another remote availability and
bootstrap dependency before the pilot has operational support for it. Keeping
plaintext credentials in environment variables, configuration files readable
by the service account, database columns, logs, or Agent environments is not an
acceptable substitute.
## Decision
### Local master-key keyring
The pilot uses a versioned **local master-key keyring**. Each key is a random
256-bit key-encryption key (KEK) with a stable, non-secret key identifier. The
keyring names exactly one active KEK and may retain previous KEKs while rotation
is in progress. It is not stored in PostgreSQL, a release directory, an
environment variable, or the ordinary database/workspace backup set.
In production, the source keyring is owned by root, mode `0600`, and delivered
to the unprivileged Hub service by systemd `LoadCredential`. Hub reads the
read-only file under `CREDENTIALS_DIRECTORY`; production startup rejects a
direct arbitrary file path. Development and tests may use an explicit local
keyring file. Startup fails when the keyring is absent, malformed, contains an
invalid key, has no active key, or names an active key that is not present.
### Envelope format and binding
Every immutable secret version receives a new random 256-bit data-encryption
key (DEK). AES-256-GCM encrypts the secret payload with that DEK. AES-256-GCM
also wraps the DEK with the keyring's active KEK. Nonces are random and never
reused with the same key.
The versioned envelope persists only algorithm/version metadata, the KEK
identifier, nonces, authentication tags, the wrapped DEK, and ciphertext.
Authenticated additional data binds both layers to the secret purpose,
Organization identifier, Connection identifier, immutable secret-version
identifier, and envelope version. Copying ciphertext between rows,
Organizations, connections, purposes, or versions therefore fails
authentication. Unknown algorithms or versions, missing KEKs, malformed
envelopes, and failed authentication are hard errors; there is no fallback to
another credential source.
The encrypted payload is a versioned object specific to its connection type.
For a Provider Connection, provider base URL and authentication material are
inside the payload. For a Feishu Application Connection, application ID,
application secret, verification/encryption material, and bot identity are
inside the payload. HTTP responses, business records, logs, metrics, traces,
audit facts, exceptions, and Agent process environments receive only redacted
metadata, never those plaintext fields or a decrypted DEK.
### Connection identity, lifecycle, and writer authority
A connection has a stable opaque identifier and belongs to exactly one
Organization. Secret versions are immutable; the connection points to one
active version. Rotation creates and validates a new version, atomically moves
the active pointer, and retains the previous encrypted version for rollback and
audit until retention policy removes it. A connection is `DRAFT`, `ACTIVE`, or
`DISABLED`; runtime resolution accepts only an active connection with a valid
active secret version.
There is one customer-owned Feishu Application Connection per Organization.
Its provider-local user, chat, event, and callback identifiers are meaningful
only together with that connection identity. Organization OWNER/ADMIN may
create and rotate that customer-owned connection. Platform repair remains a
separate stepped-up, reason-bearing, fail-closed-audited operation under
ADR-0023, not an Organization API bypass.
A model Provider Connection is unique by Organization and provider identity.
In `BYOK` mode an Organization OWNER/ADMIN creates and rotates it. In
`PLATFORM_MANAGED` mode only a stepped-up Platform Administrator may create or
rotate it, with the platform mutation and redacted Platform Audit Entry in one
transaction. Switching modes creates or activates the correctly governed
connection version; it never re-labels plaintext or grants the other control
plane write authority.
### Resolver boundary
Business code asks one connection resolver for an active connection using an
explicit Organization, or using a Project that is first resolved to its active
Organization. Provider resolution also requires the provider identity; Feishu
resolution requires the intended Feishu Connection identity whenever external
traffic enters the system. Resolution fails for missing, draft, disabled,
cross-Organization, corrupt, or undecryptable records.
Decrypted values exist only in the resolver result for the duration of the
outbound provider/Feishu operation. They are not cached across connections,
returned through administrative reads, passed to Agent tools, or written back
to business rows. Production has no process-global Feishu or model-provider
credential fallback.
The Claude Agent child never receives an Organization's Provider credential.
For each run, Hub opens a loopback-only proxy with a random, short-lived run
capability. The child receives only that local endpoint and capability; Hub
validates the capability, replaces it with the Organization credential at the
upstream hop, refuses automatic redirects, and destroys the listener when the
run ends. Agent sandbox credential rules hide even the run capability from
tool subprocesses.
### KEK rotation, backup, and recovery
KEK rotation is staged: install a new KEK alongside the old keys, mark it
active, rewrap each stored DEK under the new KEK without decrypting its payload,
verify every envelope, then retire the old KEK only after the separately
protected recovery copy and restore drill are current. Secret rotation and KEK
rotation are distinct operations.
A recoverable deployment requires both the ordinary database/workspace backup
and a separately protected backup of the local keyring. Neither copy alone can
recover plaintext credentials. Restore preflight verifies key identifiers and
authenticates sample/all envelopes before traffic is enabled. A missing or
incorrect keyring leaves the service unavailable and produces redacted
diagnostics; operators must restore the correct keyring rather than reset or
silently discard credentials.
## Consequences
- The database may be restored or copied without exposing plaintext secrets,
but losing the separately held keyring makes encrypted credentials
unrecoverable.
- The service unit, installer, preflight, backup, restore, and rotation tooling
must treat the keyring as an explicit production dependency.
- Administrative writes must accept secret material as write-only input and
return only connection state, version, timestamps, and redacted fingerprints.
- Tests must prove cross-Organization/row substitution failure, wrong/missing
key failure, version rotation, absence of global fallback, and absence of
plaintext from persistence, responses, logs, and Agent environments.
- External KMS/HSM adoption is deferred. It may replace the local KEK provider
without changing connection identity, immutable secret versions, AAD
binding, writer authority, or resolver behavior.
+57
View File
@@ -0,0 +1,57 @@
# ADR 0025: Alpha Uses Managed Single-Organization Silos
## Status
Accepted for the supervised alpha.
## Context
The shared SaaS control plane, durable cross-Organization admission scheduler,
and customer administration SPA are not complete. Real customer data must not
depend on partially migrated application-level tenant filtering merely to start
the supervised alpha.
## Decision
Each alpha Organization runs as a separately named Silo deployment. A Silo has
one Hub systemd unit and service identity, one logical PostgreSQL database and
database role, one workspace root, one environment/session secret, one local
master-key keyring, one Feishu Application Connection, and Organization-local
Provider Connections. The process is pinned by `HUB_SILO_ORGANIZATION_ID` and
refuses startup unless its database contains exactly that one active
Organization.
Application releases remain shared and versioned; customer state does not live
inside a release directory. Host-console bootstrap, upgrade, backup, restore,
and KEK rotation always name a Silo instance explicitly. The alpha exposes no
platform administration surface. Operators provision and support Silos through
the controlled host console and retain an external change record.
The process-local trigger queue is not an accepted-work queue in the Silo
alpha. A busy project or instance rejects new work explicitly. Each instance
has checked-in application limits and explicit systemd CPU, memory, and process
ceilings. Workspace filesystem quotas are a host provisioning prerequisite.
This topology changes deployment, not the semantic tenant root:
`Organization` remains the tenant root and every authorization, secret, audit,
Project, Team, and Run remains Organization-scoped. A later pooled or bridge
adapter may host multiple Organizations only after ADR-0022's durable admission
and platform control requirements are implemented.
## Consequences
- Cross-Organization data access is prevented by database credentials and
process/filesystem identity in addition to application authorization.
- Provisioning, migrations, backups, health, and upgrades are repeated per
Silo and therefore must be automated before tenant count grows materially.
- Cross-Organization fairness is not applicable inside one Silo; host cgroup
ceilings prevent one Silo from exhausting the machine.
- Database/workspace backups and the keyring/environment recovery copy are
produced to distinct protected destinations and must pass a restore drill.
## Deferred
- Shared platform identity and administration from ADR-0023.
- Pooled durable fair scheduling from ADR-0022.
- Customer org administration SPA and self-service onboarding.
- Docker or Kubernetes as an alternative deployment adapter.
+81
View File
@@ -0,0 +1,81 @@
# para-26071100 飞书应用配置清单
本文供 `para-26071100` 的飞书企业管理员操作。不要把 App Secret 粘贴到群聊、工单或本文档中;请通过约定的安全渠道交给平台部署人员。
## 1. 创建企业自建应用
1. 打开飞书开放平台开发者后台。
2. 在目标企业下创建“企业自建应用”。
3. 应用名称可填写 `Educraft para-26071100`
4. 在“凭证与基础信息”记录:
- App ID(通常以 `cli_` 开头)
- App Secret
5. 添加并启用“机器人”能力。
Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/App Secret 调用飞书 Bot Info API 获取,并在 bootstrap 时校验它确实属于这一个应用。
## 2. 开通权限
在“权限管理”中搜索并申请下列能力。飞书控制台的中文名称可能随版本调整;如控制台同时显示 scope,可优先核对括号中的 scope。
- 接收群聊中 @ 机器人的消息(`im:message.group_at_msg:readonly`
- 以应用身份发送消息(`im:message:send_as_bot`
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly``contact:user.basic_profile:readonly`
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限。
## 3. 配置事件与卡片回调
1. 进入“事件与回调”。
2. 订阅方式选择“使用长连接接收事件”。
3. 添加事件 `im.message.receive_v1`(接收消息)。
4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。
5. 不需要填写公网 Event Callback URLHub 使用飞书长连接。
## 4. 配置 OAuth 回调
域名 DNS 和 TLS 生效后,在安全设置/重定向 URL 中添加:
```text
https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback
```
该回调用于 OWNER 登录受控的 Host Console。飞书群机器人长连接本身不依赖这个 URL。
## 5. 发布并安装应用
1. 创建应用版本并提交企业管理员审核。
2. 将应用可用范围至少包含试点 OWNER 和试点群成员。
3. 发布版本。
4. 将机器人加入准备试用的飞书群。
## 6. 获取首位 OWNER 身份
平台 bootstrap 需要 OWNER 的飞书 Open ID 和显示名称。可通过飞书 API 调试台的用户信息接口查询;Open ID 通常以 `ou_` 开头。Union ID 可选,不影响首次部署。
请把以下结果通过安全渠道交给平台部署人员:
```text
Organization: para-26071100
App ID: cli_...
App Secret: (安全渠道发送)
OWNER Open ID: ou_...
OWNER 显示名称:
OWNER Union ID: (可选)
试点群名称: (可选,便于验收)
```
## 7. 验收动作
平台通知部署完成后:
1. OWNER 打开 Host Console,完成飞书 OAuth 登录。
2. 在试点群中 @机器人发送一条纯文本消息
3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。
4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
5. 再测试一个小文件附件,以及运行中断按钮。
任何一步失败时,请保留发生时间、群名、消息截图和飞书 request/log ID;不要在截图中包含 App Secret 或 Provider token。
+1
View File
@@ -0,0 +1 @@
Hello, World!
+60 -15
View File
@@ -1,23 +1,68 @@
# Hub runtime configuration. Copy to .env and fill in.
# PostgreSQL connection string. Used by Prisma and the Hub server.
# Production preflight requires the explicit production runtime mode.
NODE_ENV="production"
# PostgreSQL connection string.
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# OpenRouter (or any OpenAI-compatible) API key + base URL.
# The Hub's agent layer is provider-agnostic (ADR-0017); this points the
# OpenAI-compatible client at OpenRouter by default.
OPENROUTER_API_KEY=""
# Optional: override the base URL (e.g. direct vendor API, local gateway).
# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
# Provider URL and credentials are write-only Organization Provider Connection
# data in PostgreSQL, encrypted by ADR-0024. Process-global ANTHROPIC_* provider
# settings are rejected in production and must not be added here.
# Hub server port. Defaults to 8788.
# Model override (optional). Defaults to anthropic/claude-sonnet-5.
# Use an OpenRouter model slug, for example:
# ANTHROPIC_DEFAULT_SONNET_MODEL="~anthropic/claude-sonnet-latest"
# To use GLM:
# ANTHROPIC_DEFAULT_SONNET_MODEL="z-ai/glm-4.7"
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
# Alpha Silo safety limits. max turns may use its default; every other value is
# mandatory in production and should be calibrated on the target host.
# HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_HTTP_BODY_LIMIT_BYTES="1048576"
HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILE_BYTES="26214400"
HUB_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120"
# Persistent system-managed root for project workspaces. Production must use an
# absolute path outside the deployment/release tree; install_service.sh defaults
# to this path and rejects any overlap before installing the unit.
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID=""
HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# the file to be executable and `cph --version` to succeed.
CPH_BIN="/usr/local/bin/cph"
# Hub bind address and port. Production defaults to loopback for a local TLS
# reverse proxy; both values are validated and honored by the HTTP server.
HOST="127.0.0.1"
PORT=8788
# Feishu (lark) bot credentials. The bot receives @mentions in project groups.
FEISHU_APP_ID=""
FEISHU_APP_SECRET=""
FEISHU_BOT_OPEN_ID=""
# --- Org admin web (ADR-0021) ---------------------------------------------
# Public base URL of this Hub (no trailing slash). Used for Feishu OAuth
# redirect_uri = ${HUB_PUBLIC_BASE_URL}/auth/feishu/callback
# Configure the same callback URL in the Feishu developer console under
# Security Settings → Redirect URLs.
HUB_PUBLIC_BASE_URL="https://hub.example.com"
# Path to the `cph` binary (the Courseware-side checker, ADR-0016).
# Defaults to `cph` on PATH if unset.
# CPH_BIN="/usr/local/bin/cph"
# HMAC secret for signed session + OAuth state cookies. Generate with:
# openssl rand -base64 32
HUB_SESSION_SECRET=""
# Optional OAuth scope (space-separated). Default: contact:user.base:readonly
# Apply matching scopes in the Feishu developer console.
# HUB_OAUTH_SCOPE="contact:user.base:readonly"
# Local development/tests only. Production must not set this: systemd injects
# the fixed cph-secret-keyring credential through CREDENTIALS_DIRECTORY.
# HUB_SECRET_KEYRING_FILE="/absolute/path/to/dev-keyring.json"
@@ -0,0 +1,5 @@
{
"name": "cph-curated",
"description": "Reviewed curriculum-production skills shipped with the Curriculum Project Hub.",
"version": "0.0.1"
}
@@ -0,0 +1,72 @@
---
name: data-processing-spec
description: 物理竞赛实验「数据处理」的两套作答规范——超严格版与考试版。当用户要出实验数据处理题、或要求题目答案/解析"按考试版写""按超严格版写""按严格规范作答",或问"什么是考试版/严格版""不确定度取几位""不确定度怎么修约""连算代入哪个值""拟合要不要算 B 类"等数据处理口径问题时使用。出题与批改时据此确定唯一口径。
---
# 数据处理作答规范(超严格版 / 考试版)
物理竞赛实验数据处理里,有效数字取位、不确定度修约、连算代入、拟合是否计 B 类等环节
**各家做法不一致**。为避免"同一份数据出现多个都对的答案",本课程把这些争议点各拍板成
两套自洽的口径:
| 版本 | 用途 | 一句话特征 |
|------|------|-----------|
| **超严格版** | 严格训练 | 每一步贴近误差理论最规范做法,接受较繁的计算量 |
| **考试版** | 考试 / 日常训练 | 在规范前提下简化计算,贴近竞赛复赛阅卷习惯 |
## 怎么用这个 skill
1. **先确定版本。** 用户出题或批改时通常会说明"按考试版"还是"按超严格版"。
- 用户明确指定 → 用该版。
- 用户没指定 → **必须先问**用户要哪一版,不要自己默认。两版在四处刻意不同,
选错会给出末位不同的答案。
2. **读对应规范全文,再动手。** 选定版本后,完整阅读对应文件,按其中每一条口径生成
题目答案 / 解析 / 评分点:
- 超严格版 → [strict-spec.md](strict-spec.md)
- 考试版 → [exam-spec.md](exam-spec.md)
3. **全程只认一版。** 一道题(含所有小问)自始至终用同一版口径,不得中途混用。
4. **需要解释"为什么有两版""某争议点各方怎么做"时** → 读 [disputes.md](disputes.md)
(中立罗列各方做法与依据,不拍板)。
## 两版差异一览(仅这四处不同)
下面四项是两版**唯一的区别**;其余约定两版完全一致(见下一节)。出题/批改时重点核对这四项。
| 争议环节 | 超严格版 | 考试版 |
|----------|----------|--------|
| **不确定度取几位有效数字** | 首位为 1/2/3 取 2 位,其余取 1 位(A2) | 一律取 1 位(A1) |
| **不确定度的修约方向** | 只进不舍(偏保守,代表:北大) | 四舍六入五凑偶(代表:中科大、第 42 届复赛) |
| **多小问连算代入哪个值** | 代入前一问**未修约的真实值**,仅终值修约 | 代入前一问**已修约的填空值**,接受逐问舍入 |
| **线性拟合不确定度** | A 类 + B 类合成(需算 `u_Bk = u_By / √Σ(xix̄)²` | 只算 A 类(`u_k = σ_k` |
> 测量值(中心值)的修约:**两版都用四舍六入五凑偶**——这一条不是差异项。
## 两版共同约定(不随版本变化)
- **A 类不确定度**:取平均值的实验标准差 `u_A = √[Σ(xix̄)² / (n(n1))]`**不做 t 因子修正**。
- **B 类不确定度**`u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。合成 `u = √(u_A² + u_B²)`
- **单次测量**:不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度
(取 `u = Δ仪 / √3`)。出处:实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组
实际指导书为准。
- **有效数字总原则**:测量值位数必须与不确定度对齐——不确定度精确到哪一位,测量值就写到哪一位。
- **线性拟合 A 类**:斜率相对不确定度 `σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]`(γ 为相关系数)。
## 出题/批改自检清单
确定版本后,逐项对照所选规范,确保答案在这些点上口径一致:
- [ ] A 类是否用了"不做 t 修正"的标准差公式
- [ ] B 类是否 `Δ仪/√3`;单次测量是否用仪器误差限
- [ ] 不确定度取了几位(A2 还是 A1)—— **按版本**
- [ ] 不确定度末位修约方向(只进不舍 / 四舍六入五凑偶)—— **按版本**
- [ ] 测量值是否与不确定度对齐、是否用四舍六入五凑偶
- [ ] 多小问连算代入的是真实值还是修约值 —— **按版本**
- [ ] 线性拟合是否计 B 类 —— **按版本**
- [ ] 全卷是否始终只用了这一版口径
## 配套 PDF 源码
`scripts/` 下保留了两版规范与争议点讨论的 Typst 源码,仅作为内容参考。当前 Educraft Agent
运行时不提供独立 `typst` 命令,不要尝试直接编译这些脚本,也不要安装运行时依赖。用户需要
成品 PDF 时,明确说明当前能力边界;若内容要进入课程工程,应按 `lesson-project` 的 cph
0.0.2 结构落地并使用 `cph check/build`
@@ -0,0 +1,51 @@
# 数据处理争议点(中立罗列,不拍板)
本文件解释"为什么会有超严格版 / 考试版两套口径"——每个环节各家做法不一致,本课程把它们各
拍板成两版。这里**只中立罗列各方做法与依据**,不评对错。需要给学生/教练讲清来龙去脉时引用。
## 共同约定(无争议前提)
- A 类不确定度:实验标准差,**不做 t 因子修正**。
- B 类不确定度:`u_B = Δ仪 / √3`(均匀分布)。
- 测量值修约:四舍六入五凑偶。
- 有效数字总原则:测量值位数跟着不确定度走(对齐)。
- 单次测量:以仪器误差限估算,不假设 A 类无穷大(出处:实验指导书"杨氏模量"部分)。
## 争议点 A:不确定度取几位有效数字
- **A1(考试版采用)**:一律 1 位。如 `0.034→0.03``0.12→0.1`
- **A2(超严格版采用)**:首位为 1/2/3 时取 2 位,其余取 1 位。如 `0.123→0.12``0.67→0.7`
- 分歧本质:修约不确定度本身引入的相对误差能容忍多大;A2 为压低该相对误差而保留 2 位。
## 争议点 B:有效数字"反向多取一位"变体
- 设 `u=0.03`,再看测量值对齐位数字:≥3(如 1.87)正常对齐写 `(1.87±0.03)`;以 1/2/3 等更小
数起头(如 1.81)则允许测量值再多取一位、不确定度也反向多取一位 → `(1.812±0.034)`
- 与 A1/A2 不完全等价,是 A 的一个更细变体。本课程两版都未采用此变体(统一走 A1 或 A2),
列出仅供识别学生可能用到的写法。
## 争议点 C:不确定度本身如何修约
- **只进不舍(超严格版采用)**:末位一律进位,报告值偏保守。代表:北京大学。
- **四舍六入五凑偶(考试版采用)**:与测量值同一规则。代表:中国科学技术大学、第 42 届复赛。
- 提示:第 42 届全国中学生物理竞赛复赛对不确定度采用四舍六入五凑偶。
## 争议点 D:多小问连算代入哪个值
- **代入未修约真实值(超严格版采用)**:用完整精度中间量,仅终值修约;避免舍入误差传播,
误差理论上更规范。
- **代入已修约填空值(考试版采用)**:用前一问写出来的修约值;便于逐问复算、阅卷可追溯。
- 两者数值通常只差最后一两位,边界情形可能影响终值末位。
## 争议点 E:线性拟合是否计入 B 类
- A 类无争议:`σ_k/k = √[ (1/(n2))·(1/γ²−1) ]`
- **只算 A 类(考试版采用)**:直接 `u_k=σ_k`;相当多题目/教材实际只算 A 类,且常不说明理由。
- **A 类 + B 类合成(超严格版采用)**:把斜率写成 `k=Σci·yi``ci=(xix̄)/Σ(xjx̄)²`
`u_Bk = u_By / √(Σ(xix̄)²)`,再 `u_k=√(σ_k²+u_Bk²)`
- 为何常省略 B 类:点多、Σ(xi−x̄)² 大时 u_Bk 往往远小于 σ_k 被淹没——但这只是近似经验,非普遍成立。
## 速查对照
| 编号 | 争议内容 | 超严格版 | 考试版 |
|------|----------|----------|--------|
| A | 不确定度取几位 | 首位 1/2/3 取 2 位(A2 | 一律 1 位(A1) |
| C | 不确定度修约方向 | 只进不舍 | 四舍六入五凑偶 |
| D | 连算代入值 | 未修约真实值 | 已修约填空值 |
| E | 拟合是否计 B 类 | A 类 + B 类合成 | 只算 A 类 |
> B 项(反向多取一位变体)两版均不采用,故不在版本差异表内。
@@ -0,0 +1,73 @@
# 数据处理规范 · 考试版
> 用于**考试与日常训练**。在保证规范性的前提下**简化计算**(不确定度一律 1 位、拟合只算 A 类、
> 逐问代入修约值),贴近竞赛复赛阅卷习惯。评分以本规范为唯一口径。与超严格版在四处刻意不同
> (有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)——同一份数据两版可能给出
> 末位不同的答案,**全程只认本版,不可混用**。
## 共同约定(两版一致)
### A 类不确定度
多次测量,取平均值的实验标准差:
```
u_A = √[ Σ(xi x̄)² / (n(n1)) ]
```
- **不做 t 因子修正**,直接以上式为 u_A。
### B 类不确定度
- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。
- 合成:`u = √(u_A² + u_B²)`
### 单次测量
- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`
- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。
## 有效数字与修约(本版选定口径)
### 有效数字总原则
- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。
### 不确定度取几位有效数字 —— 统一 1 位
- **不确定度一律保留 1 位有效数字**(无论首位是几)。测量值随之对齐到该位。
- 示例:`u=0.123 → 0.1`,测量值 `1.8127 → 1.8`,记为 `(1.8 ± 0.1)``u=0.067 → 0.07`,对齐到该位。
### 测量值的修约 —— 四舍六入五凑偶
- 测量值采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。
### 不确定度的修约 —— 四舍六入五凑偶
- 不确定度也采用"四舍六入五凑偶",与测量值同一规则。
- 提示:第 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近
近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。
## 多小问连算 —— 代入上一问修约后的结果
- 后一问用到前一问结果时,**代入前一问已修约、写进答题处的那个值**进行计算。
即接受每问修约带来的舍入误差,换取逐问可复算、便于阅卷。
- 示例:杨氏模量第 1 问报告 `d = 1.8 mm`;第 2 问算 E 时**直接代入 1.8 mm**(而非未修约的 1.8127…)。
## 线性拟合 —— 只算 A 类
`y = k x + b`
- **斜率只计 A 类不确定度,不计 B 类。** 斜率相对不确定度由相关系数 γ 给出:
```
σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]
```
- 即 `u_k = σ_k`,直接作为斜率不确定度上报。
- 说明:数据点多、Σ(xi−x̄)² 较大时拟合的 B 类分量通常远小于 A 类而可忽略,本版据此**只算 A 类**
以简化计算;如需完整合成请改用超严格版。
## 速查(考试版口径)
| 项目 | 本版做法 |
|------|----------|
| A 类不确定度 | 实验标准差,不做 t 修正 |
| B 类不确定度 | Δ仪 / √3 |
| 单次测量 | 以仪器误差限估算 |
| 有效数字 | 不确定度一律 1 位 |
| 测量值修约 | 四舍六入五凑偶 |
| 不确定度修约 | 四舍六入五凑偶 |
| 连算代入 | 代入上一问修约后的结果 |
| 线性拟合 | 只算 A 类 |
@@ -0,0 +1,83 @@
// 共享样式与语义框:两份规范(超严格版 / 考试版)共用
#let rule-color = rgb("#0b4f6c")
#let note-color = rgb("#6a4c00")
#let warn-color = rgb("#b3261e")
// 规范条目框(蓝色):本规范选定的做法
#let rule(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#eaf2f6"),
stroke: (left: 3pt + rule-color),
body,
)
// 批注 / 出处框(黄色)
#let sidenote(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fbf6e8"),
stroke: (left: 3pt + note-color),
text(size: 9.5pt, body),
)
// 提醒框(红色)
#let warn(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fdeeec"),
stroke: (left: 3pt + warn-color),
text(size: 9.5pt, body),
)
// 例子框(灰色)
#let example(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#f3f3f3"),
stroke: (left: 3pt + rgb("#999")),
text(size: 9.5pt, body),
)
// 全局配置 + 封面
#let conf(title: "", subtitle: "", badge: "", badge-color: rgb("#0b4f6c"), doc) = {
set document(title: title, author: "竞赛实验教研组")
set page(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm),
numbering: "1 / 1",
number-align: center,
)
set text(font: ("Noto Serif CJK SC",), size: 10.5pt, lang: "zh", region: "cn")
set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true))
show heading: set text(font: ("Noto Sans CJK SC",))
show heading: set block(above: 1.2em, below: 0.7em)
set heading(numbering: "1.1")
show math.equation: set text(font: "New Computer Modern Math")
// 封面
align(center)[
#v(3cm)
#box(
inset: (x: 12pt, y: 6pt),
radius: 6pt,
fill: badge-color,
text(font: ("Noto Sans CJK SC",), size: 13pt, weight: "bold", fill: white, badge),
)
#v(0.9cm)
#text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold", title)
#v(0.5cm)
#text(size: 13pt, fill: rgb("#555"), subtitle)
#v(1.4cm)
#text(size: 11pt)[竞赛实验数据处理 · 评分口径规范]
#v(0.3cm)
#text(size: 10pt, fill: rgb("#777"))[供教练出题与学生研读使用]
]
pagebreak()
doc
}
@@ -0,0 +1,311 @@
// 物理竞赛中数据处理的争议点讨论
// 定位:争议点讨论为主,只中立罗列各方做法,不给本课程拍板结论。
#set document(title: "物理竞赛中数据处理的争议点讨论", author: "竞赛实验教研组")
// ---------- 字体与页面 ----------
#set page(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm),
numbering: "1 / 1",
number-align: center,
)
#set text(
font: ("Noto Serif CJK SC",),
size: 10.5pt,
lang: "zh",
region: "cn",
)
#set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true))
#show heading: set text(font: ("Noto Sans CJK SC",))
#show heading: set block(above: 1.2em, below: 0.7em)
#set heading(numbering: "1.1")
// 数学字体不指定 CJK,公式用默认 New Computer Modern Math
#show math.equation: set text(font: "New Computer Modern Math")
// ---------- 一些可复用的语义框 ----------
#let dispute-color = rgb("#b3261e")
#let calm-color = rgb("#1b5e20")
#let note-color = rgb("#6a4c00")
// 无争议约定框
#let agreed(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#eef6ee"),
stroke: (left: 3pt + calm-color),
body,
)
// 争议点框
#let dispute(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#fdeeec"),
stroke: (left: 3pt + dispute-color),
body,
)
// 批注 / 出处框
#let sidenote(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fbf6e8"),
stroke: (left: 3pt + note-color),
text(size: 9.5pt, body),
)
// 各方做法的小标签
#let school(name) = box(
inset: (x: 5pt, y: 1.5pt),
radius: 3pt,
fill: rgb("#e8eef7"),
text(size: 9pt, weight: "bold", name),
)
// ============================================================
// 封面
// ============================================================
#align(center)[
#v(3.2cm)
#text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold")[
物理竞赛中数据处理的\
争议点讨论
]
#v(0.6cm)
#text(size: 13pt, fill: rgb("#555"))[—— 不确定度、有效数字与拟合的多种规范对照 ——]
#v(1.4cm)
#text(size: 11pt)[竞赛实验数据处理 · 教研与教学参考]
#v(0.4cm)
#text(size: 10pt, fill: rgb("#777"))[供教练备课与学生研读使用]
]
#pagebreak()
// ============================================================
// 阅读说明
// ============================================================
= 这份文档怎么读
本文档的目的,是把竞赛实验数据处理中那些"两种甚至多种做法都在流传、却没有统一答案"的地方一次性摆清楚。它*不是*一份判定对错的评分标准,而是一份*争议点对照表*
- 凡是本领域已有共识、几乎不会引起争论的内容,归入 #text(fill: calm-color)[*"约定"*](绿色框),作为后续讨论的共同前提;
- 凡是各家(教材、命题、竞赛习惯)做法不一致的地方,单列为 #text(fill: dispute-color)[*"争议点"*](红色框),并尽量中立地列出每一方的做法与其依据;
- 个别需要交代来源或加以提醒的内容,用#text(fill: note-color)[*批注框*](黄色框)标出。
#sidenote[
*关于"中立"。* 本文档对每个争议点*只罗列、不拍板*。哪一套规则作为本课程或某次测验的评分口径,由教练在使用时另行约定并提前告知学生——这一点务必在出题或考试前说清楚,否则同一份数据会出现多个"都对"的答案。
]
// ============================================================
// 第一部分:共同约定
// ============================================================
= 共同约定(基本无争议)
下面几条在我们的处理体系里是稳定的前提,先固定下来,后面讨论争议时不再反复。
== A 类不确定度
多次测量下,A 类不确定度按样本标准差给出(贝塞尔公式给出的实验标准差,再除以 $sqrt(n)$ 得到平均值的标准不确定度):
$ u_A = s(overline(x)) = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#agreed[
*约定 1:A 类不确定度不做 $t$ 因子(学生 $t$ 分布)修正。* 即直接以上式作为 $u_A$,不再乘以与测量次数有关的 $t$ 因子或包含因子。这是本体系的固定口径。
]
== B 类不确定度(多次测量)
B 类不确定度由仪器误差限 $Delta_"仪"$ 给出,按均匀分布折算:
$ u_B = frac(Delta_"仪", sqrt(3)) $
#agreed[
*约定 2B 类不确定度 $= Delta_"仪" \/ sqrt(3)$。* 这里取 $sqrt(3)$ 对应仪器误差在 $plus.minus Delta_"仪"$ 区间内服从均匀分布的假设。
]
合成不确定度按方和根:$u = sqrt(u_A^2 + u_B^2)$
== 测量值的修约方式
#agreed[
*约定 3:测量值(中心值)一律采用"四舍六入五凑偶"修约。* 即逢四舍、逢六入,逢五时看前一位凑成偶数。注意:这一条只针对*测量值*;不确定度本身怎么修约是有争议的(见 @sec:round-u)。
]
#agreed[
*约定 4:测量值的位数必须与不确定度对齐。* 不确定度精确到哪一位,测量值就写到哪一位,不多写也不少写。换言之,*有效数字跟着不确定度走*——这是整个有效数字问题的总原则。争议只在于"不确定度本身取几位"以及"末位怎么修约"。
]
// ============================================================
// 第二部分:单次测量
// ============================================================
= 单次测量的特别约定
多次测量时 A 类、B 类各司其职。但有时只做*单次测量*,此时不能简单地认为"没有重复测量,A 类不确定度就趋于无穷大、结果无法估计"。
#agreed[
*约定 5(单次测量):单次测量时,直接用仪器误差限来估算该次测量的不确定度*,即把 $Delta_"仪" \/ sqrt(3)$(或按所采用规范直接用 $Delta_"仪"$)作为这一次测量结果的不确定度,而*不*假设 A 类不确定度为无穷大。
]
#sidenote[
*出处批注。* 此约定的依据来自*实验指导书中"杨氏模量"实验*的相应章节——该实验对某些只测一次的量(如仪器读数类的单次量)即采用"以仪器误差限估算单次测量误差"的处理。使用本文档时,若所在教学体系的指导书版本不同,请以本组实际采用的指导书"杨氏模量"部分为准核对此条措辞。
]
// ============================================================
// 第三部分:争议点
// ============================================================
= 争议点
以下每一条都没有"唯一正确"的答案。请教练在使用前选定口径并告知学生。
== 争议点 A:不确定度取几位有效数字 <sec:u-digits>
总原则没有争议(约定 4:测量值跟着不确定度对齐)。争议在于*不确定度本身*保留几位有效数字。
#dispute[
*做法 A1:不确定度一律取 1 位有效数字。*
无论首位是几,不确定度都只写 1 位。例如 $u = 0.034 0.03$$u = 0.12 0.1$。对应测量值也只对齐到该位。
*做法 A2:首位为 1、2、3 时取 2 位有效数字,其余取 1 位。*
当不确定度首位较小(1、2、3)时,只留 1 位会带来较大的相对截断,故允许保留 2 位。例如 $u = 0.123 0.12$(首位 1,取 2 位),而 $u = 0.67 0.7$(首位 6,取 1 位)。
]
#sidenote[
A1 A2 的分歧本质,是"修约不确定度本身引入的相对误差能容忍多大"。A2 的"1/2/3 取两位"正是为压低这一相对误差而设。两套都很常见,命题时必须二选一并写明。
]
== 争议点 B:有效数字的"反向多取一位"变体 <sec:reverse-digit>
这是争议点 A 的一个更细的变体,单独列出,因为它对测量值写法的影响最直接。
#dispute[
*做法 B("看测量值末位决定是否多取一位"):*
设不确定度形如 $u = 0.03$。再看测量值在对齐位上的数字:
- 若该位数字 $>= 3$(如测量值 $= 1.87$,末位 7),则*正常对齐*,写成 $(1.87 plus.minus 0.03)$
- 若该位数字以 1、2、3 这类较小数字开头(如测量值 $= 1.81$),则*允许测量值再多取一位*,并*相应地让不确定度也反向多取一位*,写成 $(1.81 plus.minus 0.03) (1.812 plus.minus 0.034)$ 之类。
]
#sidenote[
做法 B 的动机与 A2 一致——都是为了在数值较小时避免过度修约损失精度,只不过 B 是"由测量值末位反推是否多留一位,并让不确定度跟着多留一位"。它与 A1/A2 不完全等价,使用时要明确到底以哪条为准,避免学生在同一题里混用三套规则。
]
== 争议点 C:不确定度本身如何修约 <sec:round-u>
测量值用四舍六入五凑偶已是约定(约定 3)。但*不确定度*的修约方向有分歧。
#dispute[
*做法 C1(只进不舍 / 向上取整):* 不确定度修约时一律*只进不舍*,即末位无论被舍去的部分是多少都进位,使报告的不确定度偏保守(偏大)。
#v(0.3em)
代表口径:#school[北京大学]
*做法 C2(四舍六入五凑偶):* 不确定度与测量值一样,采用四舍六入五凑偶修约。
#v(0.3em)
代表口径:#school[中国科学技术大学] #school[ 42 届物理竞赛复赛]
]
#sidenote[
*特别提示:* 42 届全国中学生物理竞赛复赛对不确定度采用的是*四舍六入五凑偶*(即做法 C2)。若以贴近近年竞赛复赛阅卷习惯为目标,这一点值得在教学时强调;但日常训练里两种都可能遇到,仍以"出题时声明口径"为准。
]
== 争议点 D:多小问连算时,代入哪一个值 <sec:carry-value>
一道大题常有多个小问,前一问的结果会被后一问用到。典型如杨氏模量:第 1 问先求直径 $d$(含不确定度),第 2 问再用 $d$ 求杨氏模量 $E$。问题是:算 $E$ 时代入哪个 $d$
#dispute[
*做法 D1(代入未修约的"真实值"):* 用第 1 问计算过程中得到的*完整精度的 $d$*(小数点后很多位、未做修约)代入后续计算,最后只在终值处统一修约。
#v(0.3em)
依据:修约只应在*最终报告*时进行;中途代入修约值会引入*舍入误差*并逐级传播。从误差理论看这是更规范的做法。
*做法 D2(代入第 1 问已修约的填空值):* 用第 1 *答题卡上已经修约、写进横线里的那个 $d$*(如 $d = 1.81 "mm"$)代入后续计算。
#v(0.3em)
依据:答题与阅卷的可追溯性——后一问的结果应当能由前一问"写出来的答案"复现;某些阅卷口径据此判分。
]
#sidenote[
D1 是误差理论上更干净的做法(避免人为舍入误差累积),D2 则更贴合"按填写值逐问复算"的阅卷便利。两者在数值上通常只差最后一两位,但在边界情形可能影响终值修约后的末位。出题时应明确要求学生采用哪一种,并保持全卷一致。
]
== 争议点 E:线性拟合是否计入 B 类不确定度 <sec:fit>
线性拟合 $y = k x + b$ 中,斜率 $k$ *A 类*不确定度有成熟公式,无争议;争议在于*要不要再算 B 类并合成*
=== A 类(无争议部分)
斜率的 A 类相对不确定度可由相关系数 $gamma$ 表示:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $
其中 $gamma$ 为线性相关系数,$n$ 为数据点个数。这是线性拟合 A 类不确定度的常用表达,本身不引起争议。
#agreed[
*无争议:* 线性拟合斜率的 A 类不确定度套用上面的 $sigma_k \/ k$(用 $gamma$ 表示)公式。
]
=== 争议:要不要再加 B 类
#dispute[
*做法 E1(只算 A 类,不计 B 类):* 直接以拟合给出的 $sigma_k$ 作为斜率不确定度,不再考虑各测量点仪器误差带来的 B 类分量。
#v(0.3em)
现状:*相当多的题目与教材实际上只算 A 类*,而且常常*没有把"为什么忽略 B 类"说清楚*——这正是混乱的来源。
*做法 E2(A 类与 B 类合成):* 认为每个测量点都带有 B 类不确定度,应推导出斜率的 B 类分量后与 A 类方和根合成。
#v(0.3em)
现状:原则上更完整,但*少见教材给出现成公式*,需要自行推导(见下)。
]
=== 线性拟合 B 类不确定度的推导(供采用 E2 时参考)
考虑最小二乘斜率的标准表达
$ k = frac(sum_(i) (x_i - overline(x))(y_i - overline(y)), sum_(i) (x_i - overline(x))^2) = frac(sum_i (x_i - overline(x)) y_i, sum_i (x_i - overline(x))^2). $
$k$ 看成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,其中权重
$ c_i = frac(x_i - overline(x), sum_j (x_j - overline(x))^2). $
若每个 $y_i$ 带有相互独立的 B 类不确定度 $u_(B,y)$(由纵轴量的仪器误差限给出,$u_(B,y) = Delta_("仪",y) \/ sqrt(3)$,且各点近似相同),按不确定度传播:
$ u_(B,k) = sqrt(sum_i c_i^2 u_(B,y)^2) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
*斜率的 B 类不确定度等于纵轴单点 B 类不确定度,除以自变量的"离差平方和的平方根"* $sqrt(sum_i (x_i-overline(x))^2)$
如横轴量 $x$ 的仪器误差也不可忽略,可类似地把它折算到 $y$ 方向(乘以斜率 $k$)后并入 $u_(B,y)$;此处从略。最终斜率的合成不确定度为
$ u_k = sqrt(sigma_k^2 + u_(B,k)^2). $
#sidenote[
*为什么会有 E1 这种"只算 A 类"的现状?* 当数据点较多、且离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,$u_(B,k) = u_(B,y) \/ sqrt(sum_i (x_i-overline(x))^2)$ 往往远小于 A 类的 $sigma_k$,于是 B 类被"淹没"而省略。但这只是*近似成立的经验*,并非普遍正确——所以是否计入 B 类、以及是否声明忽略理由,仍是一个需要出题时明确的争议点。
]
// ============================================================
// 速查表
// ============================================================
#pagebreak()
= 争议点速查表
#table(
columns: (auto, 1fr, 1fr),
inset: 8pt,
align: (left + horizon, left, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#e8eef7") } else { white },
table.header(
[*编号*], [*争议内容*], [*主要分歧 / 代表口径*],
),
[A], [不确定度取几位有效数字], [A1 一律 1 位 / A2 首位为 1·2·3 时取 2 ],
[B], [有效数字"反向多取一位"变体], [测量值末位 ≥3 正常对齐;以 1·2·3 起更小时,测量值与不确定度同时多取一位],
[C], [不确定度本身如何修约], [C1 只进不舍(北大) / C2 四舍六入五凑偶(中科大、第 42 届复赛)],
[D], [多小问连算代入哪个值], [D1 代入未修约真实值(误差理论更规范) / D2 代入第一问已修约的填空值(便于复算阅卷)],
[E], [线性拟合是否计入 B ], [E1 只算 A 类(常见但常不说明理由) / E2 A 类与 B 类合成(需自行推导 $u_(B,k)=u_(B,y)\/sqrt(sum (x_i-overline(x))^2)$],
)
#v(0.6em)
#sidenote[
*使用建议:* 每次出题或测验前,针对表中 AE 各项各选定一种口径,连同"A 类不做 $t$ 修正""$u_B=Delta_"仪"\/sqrt(3)$""单次测量以仪器误差限估算"等约定一并写在卷首说明里。口径一旦公布,全卷保持一致,避免同一份数据出现多个"都对"的答案。
]
@@ -0,0 +1,115 @@
#import "conf.typ": conf, rule, sidenote, warn, example
#show: conf.with(
title: "数据处理规范\n考试版",
subtitle: "—— 贴近竞赛阅卷习惯、计算量适中的实用口径 ——",
badge: "考试版",
badge-color: rgb("#0b4f6c"),
)
= 规范定位
本规范用于*考试与日常训练*场景,在保证规范性的前提下*简化计算*(不确定度一律 1 位、拟合只算 A 类、逐问代入修约值),贴近竞赛复赛的阅卷习惯。学生应严格按本规范作答;评分以本规范为唯一口径。
#warn[
本规范与《超严格版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。
]
= 共同约定(两版一致)
== A 类不确定度
多次测量,A 类不确定度取平均值的实验标准差:
$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$]
== B 类不确定度
#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。]
合成:$u = sqrt(u_A^2 + u_B^2)$
== 单次测量
#rule[
*单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。
]
#sidenote[
*出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。
]
= 有效数字与修约(本版选定口径)
== 有效数字总原则
#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。]
== 不确定度取几位有效数字 —— 统一 1 位
#rule[
*不确定度一律保留 1 位有效数字*(无论首位是几)。测量值随之对齐到该位。
]
#example[
$u = 0.123 0.1$,测量值 $1.8127 1.8$,记为 $(1.8 plus.minus 0.1)$ $u = 0.067 0.07$,对齐到该位。
]
== 测量值的修约 —— 四舍六入五凑偶
#rule[*测量值采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。]
== 不确定度的修约 —— 四舍六入五凑偶
#rule[
*不确定度也采用"四舍六入五凑偶"修约*,与测量值同一规则。
]
#sidenote[
*提示:* 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。
]
= 多小问连算 —— 代入上一问修约后的结果
#rule[
大题分多小问、后一问要用到前一问结果时,*代入前一问已修约、写进答题处的那个值*进行计算。即*接受每一问修约带来的舍入误差*,换取逐问可复算、便于阅卷。
]
#example[
杨氏模量:第 1 问报告 $d = 1.8 "mm"$。第 2 问算 $E$ *直接代入 $d = 1.8 "mm"$*(而非未修约的 $1.8127...$)。
]
= 线性拟合 —— 只算 A 类
$y = k x + b$
#rule[
*线性拟合斜率只计 A 类不确定度,不计 B 类。* 斜率相对不确定度由相关系数 $gamma$ 给出:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)). $
$u_k = sigma_k$,直接作为斜率不确定度上报。
]
#sidenote[
当数据点较多、自变量离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,拟合的 B 类分量通常远小于 A 类而可忽略。本版据此*只算 A 类*以简化计算;如需完整合成请改用《超严格版》。
]
= 速查(考试版口径)
#table(
columns: (auto, 1fr),
inset: 8pt,
align: (left + horizon, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#e3edf2") } else { white },
table.header([*项目*], [*本版做法*]),
[A 类不确定度], [实验标准差,不做 $t$ 修正],
[B 类不确定度], [$Delta_"仪" \/ sqrt(3)$],
[单次测量], [以仪器误差限估算],
[有效数字], [不确定度一律 1 ],
[测量值修约], [四舍六入五凑偶],
[不确定度修约], [四舍六入五凑偶],
[连算代入], [代入上一问修约后的结果],
[线性拟合], [只算 A ],
)
@@ -0,0 +1,130 @@
#import "conf.typ": conf, rule, sidenote, warn, example
#show: conf.with(
title: "数据处理规范\n超严格版",
subtitle: "—— 每一步都按误差理论最规范的方法处理 ——",
badge: "超严格版",
badge-color: rgb("#7a1f1f"),
)
= 规范定位
本规范用于*严格训练*场景,目标是让每一步都贴近误差理论上最规范的做法,*接受较繁的计算量以换取处理的严谨性*。学生应严格按本规范作答;评分以本规范为唯一口径。
#warn[
本规范与《考试版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。
]
= 共同约定(两版一致)
== A 类不确定度
多次测量,A 类不确定度取平均值的实验标准差:
$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$]
== B 类不确定度
#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。]
合成:$u = sqrt(u_A^2 + u_B^2)$
== 单次测量
#rule[
*单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。
]
#sidenote[
*出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。
]
= 有效数字与修约(本版选定口径)
== 有效数字总原则
#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。]
== 不确定度取几位有效数字 —— 采用 A2
#rule[
*不确定度首位为 1、2、3 时保留 2 位有效数字;首位为 4\~9 时保留 1 位。*
]
#example[
$u = 0.123 0.12$(首位 1,取 2 位); $u = 0.067 0.07$(首位 6,取 1 位); $u = 0.28 0.28$(首位 2,取 2 位)。
]
== 测量值的修约 —— 四舍六入五凑偶
#rule[*测量值一律采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。]
== 不确定度的修约 —— 只进不舍
#rule[
*不确定度修约时一律"只进不舍"*:在保留位之后只要有非零数字(乃至向上保守),末位即进位,使报告的不确定度偏保守(偏大)。
]
#example[
$u = 0.121 0.13$(保留 2 位,末位进 1); $u = 0.341 0.4$(保留 1 位,进位)。
]
#sidenote[
"只进不舍"是较保守的口径(代表:北京大学)。它确保报告的不确定度不会因修约而偏小。
]
= 多小问连算 —— 代入未修约真实值
#rule[
大题分多小问、后一问要用到前一问结果时,*一律代入前一问计算所得的完整精度数值(未修约的"真实值")*,仅在每问*最终报告*时按上面的规则修约。中途不得代入已修约的填空值。
]
#example[
杨氏模量:第 1 问算得 $d = 1.8127... "mm"$(报告时修约为 $1.81 "mm"$)。第 2 问算 $E$ *代入 $d = 1.8127...$*,而非 $1.81$,避免逐级累积舍入误差。
]
= 线性拟合 —— A 类与 B 类合成
$y = k x + b$
== A 类
斜率 A 类相对不确定度由相关系数 $gamma$ 表示:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $
== B 类(本版必须计入)
把斜率写成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,权重 $c_i = (x_i - overline(x)) \/ sum_j (x_j - overline(x))^2$。设各点纵轴 B 类不确定度近似相同、为 $u_(B,y) = Delta_("仪",y)\/sqrt(3)$,按传播:
$ u_(B,k) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
#rule[
*斜率不确定度取 A 类与 B 类的方和根*
$ u_k = sqrt(sigma_k^2 + u_(B,k)^2), wide u_(B,k) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
]
#sidenote[
若横轴量 $x$ 的仪器误差不可忽略,可乘以斜率 $k$ 折算到 $y$ 方向后并入 $u_(B,y)$。当数据点多、$sum_i (x_i-overline(x))^2$ 大时 $u_(B,k)$ 往往很小,但本版*不因此省略*,一律计入。
]
= 速查(超严格版口径)
#table(
columns: (auto, 1fr),
inset: 8pt,
align: (left + horizon, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#f0e6e6") } else { white },
table.header([*项目*], [*本版做法*]),
[A 类不确定度], [实验标准差,不做 $t$ 修正],
[B 类不确定度], [$Delta_"仪" \/ sqrt(3)$],
[单次测量], [以仪器误差限估算],
[有效数字], [首位 1/2/3 2 位,其余 1 位(A2],
[测量值修约], [四舍六入五凑偶],
[不确定度修约], [只进不舍],
[连算代入], [代入未修约真实值],
[线性拟合], [A + B 类合成],
)
@@ -0,0 +1,85 @@
# 数据处理规范 · 超严格版
> 用于**严格训练**。目标:每一步贴近误差理论上最规范的做法,**接受较繁的计算量以换取严谨性**。
> 评分以本规范为唯一口径。与考试版在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、
> 拟合是否计 B 类)——同一份数据两版可能给出末位不同的答案,**全程只认本版,不可混用**。
## 共同约定(两版一致)
### A 类不确定度
多次测量,取平均值的实验标准差:
```
u_A = √[ Σ(xi x̄)² / (n(n1)) ]
```
- **不做 t 因子修正**,直接以上式为 u_A。
### B 类不确定度
- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。
- 合成:`u = √(u_A² + u_B²)`
### 单次测量
- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`
- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。
## 有效数字与修约(本版选定口径)
### 有效数字总原则
- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。
### 不确定度取几位有效数字 —— 采用 A2
- **首位为 1、2、3 时保留 2 位有效数字;首位为 4~9 时保留 1 位。**
- 示例:`u=0.123 → 0.12`(首位 1,取 2 位);`u=0.067 → 0.07`(首位 6,取 1 位);`u=0.28 → 0.28`(首位 2,取 2 位)。
### 测量值的修约 —— 四舍六入五凑偶
- 测量值一律采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。
### 不确定度的修约 —— 只进不舍
- 不确定度修约时一律**只进不舍**:保留位之后只要有非零数字即向上进位,使报告值偏保守(偏大)。
- 示例:`u=0.121 → 0.13`(保留 2 位,进位);`u=0.341 → 0.4`(保留 1 位,进位)。
- 说明:"只进不舍"是较保守口径(代表:北京大学),确保报告的不确定度不因修约而偏小。
## 多小问连算 —— 代入未修约真实值
- 后一问用到前一问结果时,**一律代入前一问计算所得的完整精度数值(未修约的真实值)**,
仅在每问**最终报告**时修约。中途不得代入已修约的填空值。
- 示例:杨氏模量第 1 问算得 `d = 1.8127… mm`(报告修约为 `1.81 mm`);第 2 问算 E 时
**代入 1.8127…**,而非 1.81,避免逐级累积舍入误差。
## 线性拟合 —— A 类与 B 类合成
`y = k x + b`
### A 类
斜率 A 类相对不确定度由相关系数 γ 表示:
```
σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]
```
### B 类(本版必须计入)
把斜率写成各 yi 的线性组合 `k = Σ ci·yi`,权重 `ci = (xi x̄) / Σ(xj x̄)²`
设各点纵轴 B 类不确定度近似相同 `u_By = Δ仪,y / √3`,按传播:
```
u_Bk = u_By · √(Σ ci²) = u_By / √( Σ(xi x̄)² )
```
### 斜率不确定度(本版上报值)
```
u_k = √( σ_k² + u_Bk² ), u_Bk = u_By / √( Σ(xi x̄)² )
```
- 若横轴量 x 的仪器误差不可忽略,可乘以斜率 k 折算到 y 方向后并入 u_By。
- 即使数据点多、Σ(xi−x̄)² 大致使 u_Bk 很小,本版**也不省略**,一律计入。
## 速查(超严格版口径)
| 项目 | 本版做法 |
|------|----------|
| A 类不确定度 | 实验标准差,不做 t 修正 |
| B 类不确定度 | Δ仪 / √3 |
| 单次测量 | 以仪器误差限估算 |
| 有效数字 | 首位 1/2/3 取 2 位,其余 1 位(A2 |
| 测量值修约 | 四舍六入五凑偶 |
| 不确定度修约 | 只进不舍 |
| 连算代入 | 代入未修约真实值 |
| 线性拟合 | A 类 + B 类合成 |
@@ -0,0 +1,24 @@
---
name: lesson-project
description: 把项目根目录 outline.md 落成符合 cph 0.0.2 的结构化讲义工程,并用 cph check/build 验证和生成教师版、学生版 PDF。
---
# 把 outline.md 落成 cph 0.0.2 工程
只在当前项目 workspace 内工作。先完整阅读 `outline.md`,再依次阅读本 skill 的 `structure.md``templates.md``workflow.md``writing-style.md`
## 不可违反的边界
- 当前唯一工程清单是 `manifest.toml`,版本契约是 `.cph-version`;不要创建旧格式 `project.toml``info.toml` 或根 `main.typ`
- element 只允许 `segment``lemma``example``sop`,字段以 `structure.md` 为准。
- 不生成 commentary、hint、answer、instruction、handout、summary 等 cph 0.0.2 不支持的字段。
- 忠实于 outline;缺题面、公式或关键结论时询问用户,不擅自补写。
- 使用 `cph check .` 验证结构,使用 `cph build . --target student``cph build . --target teacher` 构建;不要直接调用 `typst compile`
- 任一命令失败都保留完整错误并修复根因,不删除内容来糊绿。
## 完成标准
1. `cph check .` 为 0 errors。
2. 两个 `cph build` 命令退出码为 0。
3. 产物位于 `build/student.pdf``build/teacher.pdf`
4. 简报列出落地的 element、仍需用户补充的内容和两份 PDF 路径。
@@ -0,0 +1,252 @@
# 写得好的样例片段
本文件从两份现行讲义里抽取代表性片段,按 element 类型分类。看这些片段是为了对齐"写出来
就该是这样"的标准。文风、连贯性、推导风、归宿判断都靠这些样例校准——[writing-style.md]
讲方法论,本文件给出对应方法论的具体落地。
样例出处:
- EM-131 保角变换法(学生版讲义)
- 简正模(第 19 章)
---
## segment:物理引入的范例
样例摘自简正模 §19.1.1 动能的表示。
> 要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是写出体系的动能和势能
> 然后代入拉格朗日方程。这里我们假设体系的广义坐标的为 $q_1, q_2, \dots, q_n$,那么动能
> 一定可以写为
>
> $$T = \frac{1}{2}\sum_{i,j} f_{i,j}(q_1, q_2, \dots, q_n)\,\dot q_i \dot q_j .$$
>
> 其中 $f_{i,j}$ 是一个关于广义坐标的函数。例如当我们选择极坐标系描述二维空间中的运动
> 的时候,有
>
> $$T = \tfrac{1}{2} m\dot r^2 + \tfrac{1}{2} m r^2 \dot\theta^2 ,$$
>
> 可见 $\dot\theta^2$ 对应的 $f$ 为 $mr^2$。考虑到这里我们考虑的振动是在平衡位置附近的
> 小振动,广义坐标的导数 $\dot q_i$ 是小量,而在振动过程中 $f$ 的改变是一阶的,因此如
> 果仅仅保留到二阶小量,我们可以将上式改写为 ……(接下来矩阵化、对角化)
**为什么写得好**:第一句直接给出物理设置(多自由度小振动 + 普适方法)。引入一般动能形式
之后立刻举一个最简单的极坐标例子让公式落地,然后顺着"小振动→二阶小量"的物理逻辑推进到
矩阵化。整段没有"接下来要做的是""本节的核心是""为后面 X 节铺垫"这类编排话——下一步是
什么由物理决定,不需要预告。
---
## segment:概念串联的范例
样例摘自保角变换 §1.2 复势的定义。
> 考虑一个二维静电场问题,电势 $\varphi(x,y)$ 满足拉普拉斯方程。由上一节的讨论,必然
> 存在一个共轭调和函数 $\psi(x,y)$,使得 $\varphi$ 和 $\psi$ 共同构成一个解析函数
>
> $$W(z) = \varphi(x,y) + \mathrm{i}\psi(x,y),$$
>
> 称为复势。其中 $\varphi$ 为电势,$\psi$ 为流函数,电通量则正比于两条流线的流函数差值。
> 等势线 $\varphi = \text{const}$ 与电力线 $\psi = \text{const}$ 处处正交,这与式 (3)
> 的几何意义完全吻合。
>
> 从复势中提取电场只需要做一次求导。对上式求导得到
>
> $$\frac{\mathrm{d}W}{\mathrm{d}z} = \frac{\partial\varphi}{\partial x} + \mathrm{i}\frac{\partial\psi}{\partial x} = -E_x + \mathrm{i}E_y,$$
>
> 其中最后一步利用了 $E_x = -\partial\varphi/\partial x$ 以及式 (3) 给出的 $\partial\psi/\partial x = -\partial\varphi/\partial y = E_y$。
**为什么写得好**:用"由上一节的讨论""这与式 (3) 的几何意义完全吻合""利用了式 (3)"三次
回引前文,每一次都是物理推导中真正用到了前文结论。回引方式简洁、点到为止,不展开复述。
对比之下,错误的回引是"还记得我们在第 X 节讲的那个图吗,这里就是它的回扣"。
---
## lemma stmt:简洁陈述的范例
样例摘自保角变换 §1.1 末,柯西-黎曼条件的引出。
> 设复变量 $z = x + \mathrm{i}y$,考虑复变函数 $f(z) = u(x,y) + \mathrm{i}v(x,y)$
> 其中 $u$ 和 $v$ 是两个实值函数。我们要求 $f$ 的导数在复平面上处处存在且与求导方向
> 无关。沿实轴方向求导给出 ……,而沿虚轴方向求导给出 ……,两个表达式的实部和虚部分别
> 相等,立即得到柯西-黎曼条件
>
> $$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \qquad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}.$$
>
> 满足此式的函数称为解析函数。从此式可以读出一个重要的几何性质:$u$ 的梯度与 $v$ 的
> 梯度正交。这意味着 $u = \text{const}$ 与 $v = \text{const}$ 两族曲线处处正交。
**为什么写得好**:定理陈述(柯西-黎曼条件)由前面的物理设置自然推出,给出公式之后用一
两句话陈述它的几何含义。整段没有任何"这是核心定理""务必掌握""非常重要"的元评论,几何
含义陈述本身就是对定理意义的最好说明。
---
## lemma proof:纯推导的范例
样例摘自简正模 §19.1.3,证明 $\frac{\partial}{\partial q_i}\bigl(\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q}\bigr)\boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q}$。
> 将被求导的式子展开,为
>
> $$\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q} = \sum_{i,j}\tfrac{1}{2} m_{ij} q_i q_j = \sum_i \sum_j \tfrac{1}{2} m_{ij} q_i q_j .$$
>
> 考察其中与 $q_i$ 有关的部分,有可能是第一个求和取 $i$,可能是第二个求和取 $i$,也可
> 能是两个求和都取 $i$,把这三类相加为
>
> $$\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2 .$$
>
> 代回原式得到
>
> $$\text{left side} = \frac{\partial}{\partial q_i}\Bigl[\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2\Bigr]\boldsymbol{e}_i$$
> $$= \sum_{j\neq i}\bigl[\tfrac{1}{2} m_{ij} q_j + \tfrac{1}{2} m_{ji} q_j\bigr]\boldsymbol{e}_i + m_{ii} q_i \boldsymbol{e}_i$$
> $$= \sum_j m_{ij} q_j \boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q} ,$$
>
> 倒数第二个等号利用了 $\boldsymbol{M}$ 作为对称矩阵的性质。
**为什么写得好**:整段就是一连串公式加最短衔接词——"展开为""考察……部分""相加为""代回
得到""利用了……的性质"。没有"我们要做的第一步是……""现在我们考虑……""注意到这一步非常
关键……"这类讲解语言。推导自身的逻辑就是叙事,不需要再多一层元叙述。
---
## lemma proof:含分步推导的范例
样例摘自简正模 §19.1.1 末段(动能对角化的几步推进)。
> 显然我们可以适当分配交叉项使得 $\boldsymbol{M}$ 是一个对称矩阵,这意味着它可对角化。
> 令 $\boldsymbol{M}$ 的对角化形式为
>
> $$\boldsymbol{M} = \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} .$$
>
> 此时动能可以改写为
>
> $$T = \dot{\boldsymbol{q}}^\mathrm{T} \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} \dot{\boldsymbol{q}} .$$
>
> 定义新的广义坐标
>
> $$\boldsymbol{q}^* = \boldsymbol{P}^{-1} \boldsymbol{q} ,$$
>
> 又由于 $\boldsymbol{P}^{-1}$ 的每一行都是 $\boldsymbol{M}$ 的本征矢量 $\boldsymbol{x}_i$
> 也可以得到新广义坐标的各个分量为
>
> $$q_i^* = \boldsymbol{x}_i \cdot \boldsymbol{q}_i .$$
>
> 若令 $\boldsymbol{M}$ 的本征值为 $m_i$,则可以将动能写为不含广义坐标交叉项的形式,即
>
> $$T = \sum_i \tfrac{1}{2} m_i (\dot q_i^*)^2 .$$
**为什么写得好**:每一步都是一行"陈述 + 公式",陈述部分极短("令 $\boldsymbol{M}$ 的
对角化形式为""定义新的广义坐标""若令 $\boldsymbol{M}$ 的本征值为 $m_i$"),公式紧跟。
六个公式块用五个衔接句串起来,每个衔接句平均不到 10 字。
---
## example problem:题面紧凑的范例
样例摘自保角变换 EM131.14、EM131.18。
> 例 EM131.14:空间中有两个半径分别为 $R_1$ 和 $R_2$ 的一大一小两个圆柱,其中心间距
> 为 $D$,试在 $D < R_2 - R_1$ 的条件下计算两个圆柱之间的电容。
> 例 EM131.18:有一个半长轴为 $A$、短半轴为 $B$ 的无限长导体椭圆柱,将其置于沿长轴方
> 向的均匀外电场 $E_0$ 中,试求椭圆柱外的电势分布和表面电荷密度。
**为什么写得好**:题面只给"物理设置 + 所求量"两件事,参数齐全、约束条件齐全。没有"为了
练习……""下面这道题考察……""请同学们仔细思考"等元描述。
---
## example solution:纯推导的范例
样例摘自简正模例题 19.4。
> 解:不论通过对角化矩阵还是加减消元都可以很容易得到简正坐标为
>
> $$\xi_{1,2} = x_1 \pm x_2 .$$
**为什么写得好**:solution 可以很短——所求量直接由前面建立的方法得到的话,给出结果即可,
不必为了凑字数把方法再讲一遍。"不论通过对角化矩阵还是加减消元"这句话指明可走的路径,
然后立刻给结果。
---
## example solution:分步推导的范例
样例摘自简正模例题 19.5(含约当正规型求解)。
> 重新定义 $\boldsymbol{\xi}$,它的两个分量分别为 $2 x_1 + x_2$ 与 $2 x_1 - x_2$,那么
> 分量 $\xi_1$ 和 $\xi_2$ 满足的方程为
>
> $$\ddot\xi_1 + \xi_1 + \xi_2 = 0 ,$$
> $$\ddot\xi_2 + \xi_2 = 0 .$$
>
> 先求解 $\xi_2$,很容易得到通解
>
> $$\xi_2 = B \cos(t + \varphi_2) .$$
>
> 再将 $\xi_2$ 代回 $\xi_1$ 满足的方程得到
>
> $$\xi_1 = A \cos(t + \varphi_1) - \tfrac{B}{2} t \sin(t + \varphi_2) .$$
>
> 通过 $\xi_1$ 和 $\xi_2$ 反解 $x_1$ 和 $x_2$,即
>
> $$x_1 = \tfrac{\xi_1 + \xi_2}{4}, \quad x_2 = \tfrac{\xi_1 - \xi_2}{2} .$$
>
> 最终有
>
> $$x_1 = \tfrac{A}{4}\cos(t+\varphi_1) + \tfrac{B}{4}\cos(t+\varphi_2) - \tfrac{B}{8} t \sin(t+\varphi_2) ,$$
> $$x_2 = \tfrac{A}{2}\cos(t+\varphi_1) - \tfrac{B}{2}\cos(t+\varphi_2) - \tfrac{B}{4} t \sin(t+\varphi_2) .$$
**为什么写得好**:分步走的求解里每一步都用"先求解""再将……代回""通过……反解""最终有"
之类的最短衔接。每个衔接词不超过三四个字,跟在公式之间纯粹起到流向指示的作用,不夹叙
任何讲解。看完一遍这种 solution,下次自己写就该写成这个样子。
---
## 段与段之间的过渡:物理逻辑的范例
样例摘自简正模 §19.1.1 末到 §19.1.2 开头。
> 总结来说,在平衡位置附近,我们一定可以选择一组广义坐标,使得动能形式如 (19.9) 式。
>
> ## 19.1.2 势能的表示
>
> 在平衡位置附近,对振动有贡献的是势能的二阶项,不妨令其为 ……
**为什么写得好**:§19.1.1 的最后一句是对该小节内容的客观归纳("我们一定可以选择一组广义
坐标,使得动能形式如 (19.9)"),不是"接下来就讲势能"的预告。§19.1.2 第一句直接进入势能的
设置——之所以能进入,是因为已经写完动能、还差势能就能进拉格朗日方程,这是物理逻辑要求
的下一步,作者不需要在 19.1.1 末尾说"下一节会讲势能"。读者通过物理逻辑就能自然预期到
下一节的内容。
**反例(不要写成这样)**
> ……我们看到动能可以通过对角化写成无交叉项的形式。**这只是动能这一半的工作**,**接下来
> 我们要对势能做同样的事情,然后把两者代入拉格朗日方程,这是本节的核心目标**。
>
> ## 势能的表示
>
> 现在我们来处理势能 ……
反例里加粗的两句完全是元叙述,物理上没有任何新信息——拿掉这两句读者照样知道下一节是
势能。这种话出现在 textbook 里就是把大纲编排话误带进了讲义。
---
## 整体风格的负面对照
为了让样例的"好"更明显,把同样的物理内容用错误风格再写一遍。
错误版(不要这样写):
> 我们现在面对的是一个学生最容易卡住的地方——多自由度系统的小振动看起来比单摆复杂得多。
> 但其实只要找到一个统一的语言,问题就会变得清楚。这个统一的语言就是动能和势能的二次型
> 展开,再加上拉格朗日方程。本节是整章的基础,建议同学们一定要把这一节的推导完整做一遍,
> 否则后面的内容都会跟不上。下面我们先来看动能的形式。
为什么错:第一句"学生最容易卡住""看起来比单摆复杂得多"是教研判断,不该出现在学生看的
教材里;"统一的语言""会变得清楚"是情感修饰;"本节是整章的基础""建议同学们一定要……否则
后面的内容都会跟不上"是讲师对学生的指令性叙述,不是物理陈述;"下面我们先来看……"是
编排预告。
把这一段擦掉,直接写"要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是
写出体系的动能和势能然后代入拉格朗日方程"——这就是正确的范例。
@@ -0,0 +1,37 @@
# cph 0.0.2 工程结构
```text
<project>/
├── .cph-version # 固定写 0.0.2
├── manifest.toml
├── outline.md
├── exports/
│ ├── student.typ
│ └── teacher.typ
├── segments/<名称>/
│ ├── element.toml # kind = "segment"
│ └── textbook.typ
├── lemmas/<名称>/
│ ├── element.toml # kind = "lemma"
│ ├── stmt.typ
│ └── proof.typ # 可选
├── examples/<名称>/
│ ├── element.toml # kind = "example";可有 source = "..."
│ ├── problem.typ
│ └── solution.typ
├── sop/<名称>/
│ ├── element.toml # kind = "sop"
│ └── sop.typ
└── build/
```
`manifest.toml` 中的 `[[parts]]` 顺序就是最终讲义顺序。每项只写 `kind` 与相对 `path`;可选字段是否存在由 cph 在构建时解析。目录名与清单路径必须逐字一致。
当前字段契约:
- segment:必需 `textbook.typ`
- lemma:必需 `stmt.typ`,可选 `proof.typ`
- example:必需 `problem.typ``solution.typ``element.toml` 可写字符串 `source`
- sop:必需 `sop.typ`
章节标题没有独立 kind。需要在讲义中显示章节过渡时,创建一个 segment,并在 `textbook.typ` 中用 Typst 标题表达。
@@ -0,0 +1,49 @@
# cph 0.0.2 最小模板
## manifest.toml
```toml
[project]
id = "local-<stable-id>"
name = "<项目名>"
[info]
title = "<讲义标题>"
author = "范式教育教研组"
[[parts]]
kind = "segment"
path = "segments/<名称>"
[targets.student]
artifact = { type = "single-file", filepath = "build/student.pdf" }
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
[targets.teacher]
artifact = { type = "single-file", filepath = "build/teacher.pdf" }
[[targets.teacher.steps]]
type = "typst-compile"
template = "exports/teacher.typ"
```
项目 id 必须稳定且只含安全字符;已有 id 不得改。`.cph-version` 内容固定为 `0.0.2` 加换行。
## element.toml
```toml
kind = "segment"
```
将 kind 替换为对应类型。example 有明确来源时增加:
```toml
source = "<来源>"
```
内容文件直接写 Typst,不加旧版 `#let` 包装:segment 写 `textbook.typ`lemma 写 `stmt.typ`/可选 `proof.typ`example 写 `problem.typ`/`solution.typ`sop 写 `sop.typ`
## exports 模板
不要凭记忆手写长模板。优先保留项目已有的 `exports/student.typ``exports/teacher.typ`。如果新项目缺失,先向用户说明需要当前 cph 0.0.2 标准模板;不要回退到旧版 `main.typ` 架构。
@@ -0,0 +1,18 @@
# 从 outline.md 到 PDF
1. 读取 `outline.md`,按出现顺序列出类型、名称、必需内容和可选要求。
2. 检查现有工程。已有 `manifest.toml` 时保留 project id、既有内容和用户修改;不存在时使用 `templates.md` 创建最小工程。
3. 为每条大纲创建对应 element 目录和文件。所有内容只来自大纲与用户提供的材料。
4. 按大纲顺序更新 `manifest.toml``[[parts]]`
5. 运行 `cph check .`,逐条修复真实结构错误。
6. 运行:
```bash
cph build . --target student
cph build . --target teacher
```
7. 确认 `build/student.pdf``build/teacher.pdf` 存在且非空。
8. 如用户需要,通过受控的 `send_file` 工具发送产物;不要用任意网络命令外传文件。
若 outline 缺少 example 的题面或解析、lemma 的明确结论,必须在创建不完整 element 前询问用户。不要留下能通过检查但内容虚假的占位文本。

Some files were not shown because too many files have changed in this diff Show More