Files
bisheng/docs/constitution.md
LineWalker 2d0c645ba2 fix(linsight): 技能 bundle 改存对象存储,多节点下不再各存各的
技能正文/脚本/附件此前以节点本地磁盘(SKILLS_ROOT)为权威存储,DB 只存元数据。单机
compose 下 backend 与 backend_worker 恰好 bind-mount 同一个 /app/data 才让它工作;两者
一旦不在同一台宿主机,A 机上传的技能在 B 机 worker 上读不到,而失败是静默的——任务照跑,
只是技能不生效。

根因不在最近那批内置技能提交。F035 设计时就写明了这个约束(035 design.md:580),还留了
「演进为 MinIO 正文 + 本地物化」的出路,但三条后续动作(写进部署文档、启动自检、对象存储
化)一件都没落地——把架构问题降级成了一条没人执行的运维契约。294a0f35b 只是把开箱默认启用
的三个官方技能也接进这条链路,把受害面从「用户自建技能」放大到「所有租户默认就有的技能」。

改为对象存储内容寻址为唯一权威 + 节点按内容哈希本地物化:

- 键 linsight/skills/{tenant_id}/{name}/{content_hash}.zip。哈希算在文件映射上而非打包
  字节上:zipfile 写入 time.localtime() 且顺序随 dict 迭代,拿归档字节当版本会让 seeder
  每次启动都判定「变了」,重写所有租户所有技能,本地缓存目录每启动一次多一份。
- 哈希进键、而不是固定 {name}.zip:并发编辑时两次 PUT 落在两个对象上,DB 无论哪次 UPDATE
  胜出都指向内容匹配的对象。固定键会出现对象留 B、行写 A 的永久分叉且无法自愈。副作用是
  彻底不需要 HEAD/etag 探测(etag 在 multipart 下本就不是内容哈希,而上传上限正好 10MB)。
- 写路径不清理旧版本:A 写 hash1、B 写 hash2 时,A 的清理会删掉 B 刚发布且 DB 已指向的
  对象——那等于把内容寻址刚消解的竞态请回来。只有 delete(整个技能没了)才按 prefix 全删,
  孤儿对象留给 ops 回收。
- 物化是第二条写盘路径,重新 enforce MAX_UNPACKED_SIZE 与 _safe_rel_path:上传路径的两道
  闸(在 _parse_upload 与旧 write_bundle 里)都不在这条路上,损坏或被篡改的对象本可以写满
  磁盘或写出缓存目录外。

失败不再静默:区分「用户勾了但没加载成功」与「没勾」,前者推一条时间线提示(复用既有 step
通道,未新增 MessageEventType 枚举),三语齐。这正是本次改造的动机——换了存储却不动这里,
MinIO 抖动会以完全相同的方式复现同一个 bug。

顺带修掉:seeder 把所有 DB 异常都当并发竞争(收窄为 IntegrityError,否则连接失败、字段超长
也会被记成「另一副本赢了」);幂等改为比哈希且确认对象存在,保住原先逐字节比对附带的「对象
被误删则下次启动自愈」;skill_service._load_existing_bundle 是全仓唯一一处绕过 Store API 直
拼路径的逃逸;删掉 DORMANT 的 skill_middleware.py 与 built-in/ 死路径(对象存储下这一层语义
不存在,留着就是必然找不到文件的方法)。

存量迁移:ops 脚本为主,每台曾服务过 API 的机器都要跑一次;启动期只做极窄自愈——仅发布本机
确实持有、且字节数与 DB 记录一致的 bundle,其余按技能名报出来。多副本各自看到不同本地盘时,
让谁都能发布自己那份,胜出者就是随机的,那正是这次要消除的东西。回滚需先跑
restore_skills_to_local.py,否则新版本期间创建的技能在旧代码下静默失效。

约束本身上升为架构宪法 C8(禁止本地文件系统承载跨进程共享状态),并补齐 08-deployment.md 的
多节点章节——顺带修掉该文档两处已经写错的表述:它先前声称 SOP 迁移脚本「写对象存储」,实际
写的是本地盘,本次改完才成立。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:33:30 +08:00

9.9 KiB
Raw Permalink Blame History

BiSheng Architecture Constitution

The single source of truth for BiSheng's architectural laws — invariant across all features, never to be violated.

  • AGENTS.md and every feature's design.md reference this file; they never copy it. Changing an implementation never requires editing this file (they point here).
  • scripts/arch-guard.sh is the machine-enforcement arm of this document: each RULE maps to a clause below (see the anchor table).
  • Violations are reported as BLOCKER during /sdd-review design.
  • Change governance: editing this file requires PR review (a law change affects every feature). If a RULE is involved, sync the "→ Cx" note in arch-guard.sh.
  • Last revised: 2026-08-14 (C8: no shared state on the local filesystem).

Anchor Table (clause ↔ arch-guard RULE)

Clause Law arch-guard RULE Severity
C1 DDD layered call chain RULE-1 / 2 / 3 / 4 / 5 VIOLATION (RULE-3 is WARNING during migration)
C2 Dual-DB compatibility (MySQL + DM8) — (review + CI)
C3 Multi-tenancy auto-injection — (review)
C4 Permission unified entry point RULE-8 / 9 VIOLATION
C5 Error-code convention — (review)
C6 No hardcoded secrets RULE-7 WARNING
C7 Frontend store must not call HTTP directly RULE-6 WARNING
C8 No shared state on the local filesystem — (review)

C1. DDD Layered Call Chain

Call chain — never skip layers: Router → Endpoint → Service → Repository → DB

  • Never import bisheng.database.models.* in endpoints — go through a Domain Service/DAO (RULE-3, WARNING during migration).
  • Never write ORM queries in Service; never add new DAO entry points for new features.
  • common/, core/ must not import domain/, api/ (RULE-1).
  • database/models/ must not import domain/ (RULE-2).
  • domain/models/ must not import domain/services/ (RULE-4).
  • The API layer must not cross-import between modules (RULE-5).

C2. Dual-DB Compatibility (MySQL + DM8) ⚠️

Every new feature must work on both dialects. DM8 is not optional.

Use Never use
dialect_helpers.JsonType sqlalchemy.JSON, mysql.JSON
dialect_helpers.LargeText LONGTEXT, MEDIUMTEXT
dialect_helpers.UPDATE_TIME_SERVER_DEFAULT ON UPDATE CURRENT_TIMESTAMP
SQLAlchemy inspect() information_schema, DATABASE()
Explicit relational columns JSON_EXTRACT / JSON_CONTAINS / JSON_SEARCH

macOS: the DM8 driver (dmPython/dmAsync) is not installed (sys_platform != 'darwin'). DM8 is a development hard-requirement — always use dialect_helpers, never MySQL-only syntax. But DM8 compatibility is verified by a central regression run (pre-release / periodic, on Linux), not by per-feature CI gates — day-to-day it's held by this law + review, not by a per-PR DM8 test.

C3. Multi-Tenancy — Auto-Injected, Never Manual

Never write WHERE tenant_id = X manually. SQLAlchemy events handle it automatically for 23+ tables. multi_tenant.enabled=false behaves identically to single-tenant (default tenant_id=1).

C4. Permissions — Verified Business Target + Unified Action Runtime

from bisheng.permission.application.business_authorization import (
    require_business_action,
)

await require_business_action(
    login_user,
    resource_type="workflow",
    resource_id=workflow_id,
    action="edit",
)
  • The owning business Service/adapter first loads the resource, checks tenant/status/parent/version rules, then creates VerifiedPermissionTarget. The permission module only performs business-independent authorization; it must not import or query business ORM/DAO/Repository/Service objects.
  • Never query role_access, OpenFGA, or legacy relation/permission_id templates directly for resource authorization (RULE-8 / historical invariant INV-T19, VIOLATION). Concrete actions go through the sole F048 runtime.
  • Resource creation/move/copy/delete, Grant mutation, mode switch, and Catalog publish must pre-record a durable operation/tuple projection ledger before OpenFGA mutation. SQL finalize is allowed only after the atomic projection succeeds; retry/forward repair uses the same idempotency key and frozen plan.
  • Concrete resource decisions short-circuit in this order: super_admin → tenant mismatch deny → tenant admin → Catalog/action gate → OpenFGA. RBAC menu access remains a separate navigation/API-capability concern and is never a fallback ALLOW for resource actions.
  • Business modules depend only on application protocols exported by permission.application. They must never import an OpenFGA client/manager, construct transport tuples, or branch on OpenFGA-specific errors. Identity checks, relation queries, grants, revokes, and projection mutations all pass through the permission module; only permission infrastructure and explicit operational migration tools may access OpenFGA directly (RULE-9).
  • Production resolves one unique OpenFGA Store by stable name and its latest model on first permission-runtime access, then requires that Store/model/checksum to match the one ACTIVE authorization release referenced by the SQL CURRENT Catalog. Every Check/List/Write still sends that resolved model ID explicitly. Legacy/ dual-model clients, runtime model writes, and fail-open behavior are forbidden. During an explicit version upgrade, a predecessor model or an incomplete CURRENT Catalog must fail the lazy permission Context closed: it must not publish a ready heartbeat, serve production authorization, or start data migration. Migration traffic control belongs to deployment/ingress and queue operations; application health checks and global HTTP/WebSocket/Celery/ Linsight gates must not encode the one-time migration procedure.

C5. Error-Code Convention

  • 5-digit MMMEE (3-digit module + 2-digit error), defined in common/errcode/.

Module registry (34 in use as of 2026-08-06). The authoritative source is always the Code: int = NNNNN literals themselves; this table mirrors them and will drift. Before claiming a new module number, re-derive the list:

grep -rhoE "Code:\s*int\s*=\s*[0-9]{5}" src/backend/bisheng/common/errcode/*.py \
  | grep -oE "[0-9]{5}" | cut -c1-3 | sort -un
Range Assignments
10x 100 server · 101 finetune · 102 model_deploy · 103 component · 104 assistant · 105 flow · 106 user · 107 tag · 108 llm · 109 knowledge
11x 110 linsight · 111 linsight (second block)
12x18x 120 workstation · 140 message · 150 tool · 160 dataset · 170 telemetry · 180 knowledge_space · 181 approval
19x (tenant / permission) 190 channel and permission ⚠️ · 191 tenant_resolver · 192 tenant_fga · 193 sso_sync · 194 tenant_quota · 195 tenant_sharing · 196 resource_owner_transfer · 197 admin_scope · 198 llm_tenant
20x25x (org) 200 tenant · 210 department · 220 org_sync and tenant_tree ⚠️ · 230 user_group · 240 role · 250 permission
  • ⚠️ 190 and 220 are each shared by two modules — pre-existing collisions, not a precedent. Never reuse an occupied number.
  • 130 was registered as chat but is not used by any error code. Do not treat it as free without checking; do not cite it as an example.
  • 260 is reserved for the Open API (/api/v2) authentication module. Not yet implemented — do not claim it for anything else.
  • When you claim a number, add it here in the same change.

C6. No Hardcoded Secrets (RULE-7)

No password / secret_key / api_key / access_token literals in code. Use config + Fernet encryption (passwords in config.yaml are Fernet-encrypted; never write plaintext).

C7. Frontend Store Must Not Call HTTP Directly (RULE-6)

A frontend store must not call HTTP directly — go through controllers/API/ (platform) or api/ (client). All other frontend conventions (state library, UI library, path aliases, i18n, Toast, etc.) live in .claude/rules/platform-frontend.md and .claude/rules/client-frontend.md (see also AGENTS.md §4).

C8. No Shared State on the Local Filesystem ⚠️

Multi-node is the default assumption, not an edge case. The backend already runs as several processes that need not share a machine: API replicas (uvicorn --workers), Celery workers, the Linsight worker (bisheng/linsight/worker.py, hostname-derived node_id + heartbeats), and Beat. Two processes agreeing today only because a single-host docker compose happens to bind-mount the same /app/data is an accident, not a design.

The authoritative store for anything read by more than one process is MySQL/DM8, Redis, or MinIO. The local filesystem is a cache: disposable, rebuildable, never the source of truth.

Use Never
Object storage for bytes + DB row for the pointer A DB row whose payload only exists on the writer's disk
Content-addressed keys, local cache keyed by that hash A mutable local path treated as the live copy
Startup work registered in every process role that needs it Initialization only in main.py's FastAPI lifespan
Fail loudly, or report the gap to the user Log a warning and continue silently degraded

Reference implementations: WorkspaceBackend (MinIO truth + write-through cache) and SkillStore (content-addressed objects + local materialization) in bisheng/linsight/domain/services/.

Precedents that make this a law rather than advice: skill bundles shipped as node-local files and were unreadable from any other host (fixed by moving them to object storage); the F048 resource registry was installed only in the API process and had to be retrofitted into the background workers (02cbb921a). Both failed silently — which is the real cost, and why the last row of the table matters as much as the first.