Compare commits

..

11 Commits

Author SHA1 Message Date
sjfhsjfh 3ebe4b754d refactor(spec): clean prose patterns across all modules
Remove filler/redundant patterns: 钉死/钉, 本模块, likec4 画不出/画得出,
臆造, 散文, 分歧点测试, 纯 plumbing, 恰好, 留白, 宪法第N条, 刻意.
No code definitions changed, only doc comments.
2026-07-13 11:26:23 +08:00
sjfhsjfh 3fa6a5a5a5 fix(spec): replace @Claude with @bot in Prelude and Run 2026-07-12 18:55:52 +08:00
sjfhsjfh be4260bcd0 refactor(spec): move AgentRole/Run/Memory/AgentSurface into System/Agent/ subdir 2026-07-12 18:43:11 +08:00
sjfhsjfh a4449f03c4 chore: ignore .env 2026-07-12 18:38:45 +08:00
sjfhsjfh 01bc20d25f feat(spec): add AgentRole, AgentSkill, RoleSkillBinding (ADR-0017/0018) 2026-07-12 18:38:17 +08:00
sjfhsjfh 38c3231190 refactor(spec): generalize Feishu to Connections
- Connections/Prelude.lean: ConnectionProvider 枚举 (当前仅飞书)
- Connections/Feishu.lean: FeishuAppBinding + FeishuProfile
- Connections.lean: ConnectionBinding/ConnectionProfile inductive
- Organization.feishu → connections: List ConnectionBinding
- User.feishu → connections: List ConnectionProfile
- 删除 FeishuConnection.lean
2026-07-12 15:54:16 +08:00
sjfhsjfh 63416e06ea refactor(spec): move FeishuProfile to FeishuConnection, clean docs
- FeishuProfile 从 User.lean 移到 FeishuConnection.lean
- User.lean 只留用户创建路径声明
- 清理所有 doc comment
2026-07-12 09:33:33 +08:00
sjfhsjfh 39bd2c9ff7 feat(spec): pin org-feishu app binding to 1:1
- FeishuConnection.lean: FeishuAppBinding (appId + appSecretEnvelope)
- Organization.feishu: Option FeishuAppBinding (Option 自带 1:1)
- 删除 FeishuConnectionId (不再需要游离类型)
- FeishuProfile 删除 connection 字段 (由 org 隐含)
2026-07-12 09:29:41 +08:00
sjfhsjfh e17e038232 feat(spec): add FeishuUserId to FeishuProfile
飞书 user_id 是租户内身份,换应用不变;open_id 是应用内身份,换应用即变。
两者都存:user_id 更稳定,open_id 是 API 调用句柄。
2026-07-12 09:26:13 +08:00
sjfhsjfh 678bc9f56c refactor(spec): tighten prose, replace jargon
- 角色格→角色体系 (3处)
- 租户根/tenant root→租户 (3处)
- 清理 Hierarchy/Organization/System 的 doc 注释
2026-07-12 09:23:39 +08:00
sjfhsjfh 3a50ed0ce2 feat(spec): add three-tier subject hierarchy and org role lattice
- Hierarchy.lean: Platform/Organization/User struct, 三层主体层级
- User.lean: FeishuProfile, 飞书身份是绑定不是本体
- User struct: id/displayName/passwordHash/feishu
- Organization.lean: OrganizationRole(owner/admin/member) + 成员管理规则
- Prelude.lean: UserId/FeishuOpenId/FeishuConnectionId

实现偏离: spec 钉 User 为独立实体, 实现 User.id 由飞书身份派生

lake build 35/35 全绿
2026-07-12 00:21:13 +08:00
47 changed files with 430 additions and 1067 deletions
+3
View File
@@ -11,6 +11,9 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/
# Environment
.env
# Node (hub/ TS workspace and any future JS package)
node_modules/
@@ -1,59 +0,0 @@
#!/usr/bin/env node
import { readdir, readFile, realpath } from "node:fs/promises";
import { dirname, relative, resolve, sep } from "node:path";
const rootArgument = process.argv[2];
if (!rootArgument) throw new Error("usage: build_legacy_project_manifest.mjs <legacy-workspaces-root>");
const root = await realpath(rootArgument);
const projectFiles = await findProjectFiles(root);
const seenIds = new Set();
const manifest = [];
for (const projectFile of projectFiles) {
const metadata = JSON.parse(await readFile(projectFile, "utf8"));
if (typeof metadata.id !== "string" || metadata.id.trim() === "") {
throw new Error(`project metadata has no id: ${projectFile}`);
}
if (seenIds.has(metadata.id)) throw new Error(`duplicate project id: ${metadata.id}`);
seenIds.add(metadata.id);
if (typeof metadata.name !== "string" || metadata.name.trim() === "") {
throw new Error(`project metadata has no name: ${projectFile}`);
}
const projectRoot = dirname(projectFile);
const sourceRelativePath = relative(root, projectRoot).split(sep).join("/");
const physicalFolderPath = dirname(sourceRelativePath) === "."
? []
: dirname(sourceRelativePath).split("/");
if (metadata.folderPath !== undefined && (
!Array.isArray(metadata.folderPath)
|| metadata.folderPath.some((part) => typeof part !== "string")
|| JSON.stringify(metadata.folderPath) !== JSON.stringify(physicalFolderPath)
)) {
process.stderr.write(`[legacy-manifest] stale metadata folderPath; using physical path: ${projectFile}\n`);
}
manifest.push({
legacyId: metadata.id,
name: metadata.name,
folderPath: physicalFolderPath,
sourceRelativePath,
});
}
manifest.sort((left, right) => left.sourceRelativePath.localeCompare(right.sourceRelativePath, "zh-CN"));
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
async function findProjectFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const projectMetadata = entries.find((entry) => entry.isFile() && entry.name === "project.json");
if (projectMetadata !== undefined) return [resolve(directory, projectMetadata.name)];
const found = [];
for (const entry of entries) {
if (entry.name === ".trash") continue;
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) {
process.stderr.write(`[legacy-manifest] skip untracked symbolic link: ${path}\n`);
continue;
}
if (entry.isDirectory()) found.push(...await findProjectFiles(path));
}
return found;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.22",
"version": "0.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.22",
"version": "0.0.20",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.22",
"version": "0.0.20",
"private": true,
"type": "module",
"engines": {
@@ -1,48 +0,0 @@
import { readFile } from "node:fs/promises";
import { prisma } from "../db.js";
import { importLegacyProjects, type LegacyProjectManifestEntry } from "./legacyProjectImport.js";
import { readSiloOrganizationId } from "./silo.js";
async function main(argv: readonly string[]): Promise<void> {
const options = parseOptions(argv);
const organizationId = readSiloOrganizationId();
const manifest = JSON.parse(await readFile(required(options, "manifest"), "utf8")) as unknown;
if (!Array.isArray(manifest)) throw new Error("legacy import manifest must be a JSON array");
const state = await importLegacyProjects({
prisma,
organizationId,
actorFeishuOpenId: required(options, "actor-open-id"),
workspaceRoot: required(options, "workspace-root"),
sourceRoot: required(options, "source-root"),
stateFile: required(options, "state-file"),
projects: manifest as LegacyProjectManifestEntry[],
onProgress: (message) => console.error(`[legacy-import] ${message}`),
});
console.log(JSON.stringify({ imported: Object.keys(state.projects).length }));
}
function parseOptions(args: readonly string[]): Map<string, string> {
const options = new Map<string, string>();
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (flag === undefined || !flag.startsWith("--") || value === undefined) {
throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`);
}
options.set(flag.slice(2), value);
}
return options;
}
function required(options: ReadonlyMap<string, string>, name: string): string {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
main(process.argv.slice(2))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
})
.finally(async () => prisma.$disconnect());
-369
View File
@@ -1,369 +0,0 @@
import { createHash } from "node:crypto";
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { createFolder, createProjectFromOrgAdmin } from "../projectOnboarding.js";
export interface LegacyProjectManifestEntry {
readonly legacyId: string;
readonly name: string;
readonly folderPath: readonly string[];
readonly sourceRelativePath: string;
}
export interface LegacyProjectImportState {
readonly version: 1;
readonly projects: Readonly<Record<string, {
readonly status: "PENDING" | "COMPLETED";
readonly projectId: string;
readonly workspaceDir: string;
readonly importedAt: string;
readonly sourceRelativePath: string;
}>>;
}
export async function importLegacyProjects(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly workspaceRoot: string;
readonly sourceRoot: string;
readonly stateFile: string;
readonly projects: readonly LegacyProjectManifestEntry[];
readonly onProgress?: (message: string) => void;
}): Promise<LegacyProjectImportState> {
const sourceRoot = await realpath(input.sourceRoot);
const state = await readState(input.stateFile);
const projects = { ...state.projects };
const seen = new Set<string>();
for (const entry of input.projects) {
validateEntry(entry);
if (seen.has(entry.legacyId)) throw new Error(`duplicate legacy project id: ${entry.legacyId}`);
seen.add(entry.legacyId);
const sourceDir = await confinedSourceDir(sourceRoot, entry.sourceRelativePath);
const projectId = importedProjectId(input.organizationId, entry.legacyId);
const existing = await input.prisma.project.findUnique({ where: { id: projectId } });
const recorded = projects[entry.legacyId];
if (recorded !== undefined && (recorded.projectId !== projectId || recorded.sourceRelativePath !== entry.sourceRelativePath)) {
throw new Error(`legacy import state identity conflict: ${entry.legacyId}`);
}
if (recorded?.status === "COMPLETED" && existing === null) {
throw new Error(`legacy import state references missing project: ${entry.legacyId} -> ${recorded.projectId}`);
}
if (existing !== null) {
if (existing.organizationId !== input.organizationId || (recorded !== undefined && recorded.projectId !== existing.id)) {
throw new Error(`legacy import identity conflict: ${entry.legacyId} -> ${existing.id}`);
}
if (await hasCompletionMarker(existing.workspaceDir, entry)) {
await ensureImportAudit(input.prisma, existing.id, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: existing.id,
workspaceDir: existing.workspaceDir,
importedAt: recorded?.importedAt || new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
input.onProgress?.(`skip ${entry.legacyId}: recovered completed import`);
continue;
}
if (recorded?.status !== "PENDING") {
throw new Error(`refusing to remove legacy project without a matching pending record: ${entry.legacyId}`);
}
input.onProgress?.(`recover ${entry.legacyId}: remove incomplete target`);
await removeIncompleteTarget(input.prisma, existing.id, existing.workspaceDir, input.workspaceRoot);
}
projects[entry.legacyId] = {
status: "PENDING",
projectId,
workspaceDir: "",
importedAt: "",
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
const folderId = await ensureFolderPath(input.prisma, input.organizationId, ["旧教学资产", ...entry.folderPath]);
input.onProgress?.(`import ${entry.legacyId}: ${entry.name}`);
const created = await createProjectFromOrgAdmin(input.prisma, {
organizationId: input.organizationId,
actorFeishuOpenId: input.actorFeishuOpenId,
name: entry.name,
workspaceRoot: input.workspaceRoot,
folderId,
projectId,
});
try {
await copyLegacyProject(sourceDir, created.workspaceDir, entry);
} catch (error) {
try {
await removeIncompleteTarget(input.prisma, created.projectId, created.workspaceDir, input.workspaceRoot);
delete projects[entry.legacyId];
await writeState(input.stateFile, { version: 1, projects });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`legacy project import and target cleanup failed: ${entry.legacyId}`,
);
}
throw new Error(`legacy project import failed: ${entry.legacyId}: ${errorMessage(error)}`, { cause: error });
}
await ensureImportAudit(input.prisma, created.projectId, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: created.projectId,
workspaceDir: created.workspaceDir,
importedAt: new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
}
return { version: 1, projects };
}
async function ensureFolderPath(
prisma: PrismaClient,
organizationId: string,
parts: readonly string[],
): Promise<string> {
let parentId: string | undefined;
for (const name of parts) {
const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: parentId ?? null, name, archivedAt: null },
select: { id: true },
});
if (existing !== null) {
parentId = existing.id;
continue;
}
const created = await createFolder(prisma, {
organizationId,
name,
...(parentId !== undefined ? { parentId } : {}),
});
parentId = created.id;
}
if (parentId === undefined) throw new Error("legacy import folder path is empty");
return parentId;
}
async function copyLegacyProject(
sourceDir: string,
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const sourceWorkspace = join(sourceDir, "workspace");
await assertNoSymlinks(sourceWorkspace);
const names = await readdir(sourceWorkspace);
for (const name of names) {
if (name === ".claude" || name === ".cph") continue;
await cp(join(sourceWorkspace, name), join(workspaceDir, name), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
filter: (source) => {
const parts = relative(sourceWorkspace, source).split(sep);
return !parts.includes(".claude") && !parts.includes(".cph");
},
});
}
const legacyDir = join(workspaceDir, ".legacy-source");
await mkdir(legacyDir, { mode: 0o750 });
await cp(join(sourceDir, "project.json"), join(legacyDir, "project.json"), {
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
const rawDir = join(sourceDir, "_raw");
await assertNoSymlinks(rawDir).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await cp(rawDir, join(legacyDir, "raw"), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
}).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await writeFile(join(legacyDir, "migration.json"), `${JSON.stringify({
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
migratedAt: new Date().toISOString(),
}, null, 2)}\n`, { mode: 0o640 });
}
function importedProjectId(organizationId: string, legacyId: string): string {
const digest = createHash("sha256")
.update("teaching-material-host-service\0")
.update(organizationId)
.update("\0")
.update(legacyId)
.digest("hex")
.slice(0, 32);
return `legacy_${digest}`;
}
async function hasCompletionMarker(
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<boolean> {
try {
const marker = JSON.parse(await readFile(join(workspaceDir, ".legacy-source", "migration.json"), "utf8")) as unknown;
return typeof marker === "object" && marker !== null
&& "legacyProjectId" in marker && marker.legacyProjectId === entry.legacyId
&& "legacyPath" in marker && marker.legacyPath === entry.sourceRelativePath;
} catch (error) {
if (isMissing(error)) return false;
if (error instanceof SyntaxError) {
throw new Error(`invalid legacy completion marker: ${workspaceDir}`, { cause: error });
}
throw error;
}
}
async function ensureImportAudit(
prisma: PrismaClient,
projectId: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const existing = await prisma.auditEntry.findFirst({
where: { projectId, action: "legacy_project.imported" },
select: { id: true },
});
if (existing !== null) return;
await prisma.auditEntry.create({
data: {
projectId,
action: "legacy_project.imported",
metadata: {
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
},
},
});
}
async function removeIncompleteTarget(
prisma: PrismaClient,
projectId: string,
workspaceDir: string,
workspaceRoot: string,
): Promise<void> {
await assertConfinedExistingPath(workspaceRoot, workspaceDir);
const failures: unknown[] = [];
try {
await prisma.project.delete({ where: { id: projectId } });
} catch (error) {
failures.push(error);
}
try {
await rm(workspaceDir, { recursive: true, force: true });
} catch (error) {
failures.push(error);
}
if (failures.length > 0) throw new AggregateError(failures, `failed to remove incomplete legacy target: ${projectId}`);
}
async function assertConfinedExistingPath(root: string, path: string): Promise<void> {
const trustedRoot = await realpath(root);
const candidate = await realpath(path);
const rel = relative(trustedRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`refusing to remove path outside workspace root: ${path}`);
}
}
async function assertNoSymlinks(path: string): Promise<void> {
const metadata = await lstat(path);
if (metadata.isSymbolicLink()) throw new Error(`legacy import rejects symbolic link: ${path}`);
if (!metadata.isDirectory()) return;
for (const entry of await readdir(path)) await assertNoSymlinks(join(path, entry));
}
async function confinedSourceDir(sourceRoot: string, relativePath: string): Promise<string> {
const candidate = await realpath(resolve(sourceRoot, relativePath));
const rel = relative(sourceRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`legacy source path escapes source root: ${relativePath}`);
}
return candidate;
}
function validateEntry(entry: LegacyProjectManifestEntry): void {
if (typeof entry !== "object" || entry === null) throw new Error("invalid legacy project entry");
if (typeof entry.legacyId !== "string") throw new Error("legacy project id must be a string");
if (!/^[A-Za-z0-9_-]+$/.test(entry.legacyId)) throw new Error(`invalid legacy project id: ${entry.legacyId}`);
if (typeof entry.name !== "string") throw new Error(`legacy project name must be a string: ${entry.legacyId}`);
if (entry.name.trim() === "") throw new Error(`legacy project name is empty: ${entry.legacyId}`);
if (typeof entry.sourceRelativePath !== "string") {
throw new Error(`legacy source path must be a string: ${entry.legacyId}`);
}
if (entry.sourceRelativePath === "" || resolve("/", entry.sourceRelativePath) === "/") {
throw new Error(`invalid legacy source path: ${entry.legacyId}`);
}
if (!Array.isArray(entry.folderPath)) throw new Error(`legacy folder path must be an array: ${entry.legacyId}`);
for (const part of entry.folderPath) {
if (typeof part !== "string" || part.trim() === "" || part === "." || part === ".." || part.includes("/") || part.includes("\\")) {
throw new Error(`invalid legacy folder part for ${entry.legacyId}: ${part}`);
}
}
}
async function readState(path: string): Promise<LegacyProjectImportState> {
try {
const parsed = JSON.parse(await readFile(path, "utf8")) as LegacyProjectImportState;
if (parsed.version !== 1 || typeof parsed.projects !== "object" || parsed.projects === null) {
throw new Error(`invalid legacy import state: ${path}`);
}
for (const [legacyId, record] of Object.entries(parsed.projects)) validateStateRecord(path, legacyId, record);
return parsed;
} catch (error) {
if (isMissing(error)) return { version: 1, projects: {} };
throw error;
}
}
function validateStateRecord(path: string, legacyId: string, record: unknown): void {
if (typeof record !== "object" || record === null || Array.isArray(record)) {
throw new Error(`invalid legacy import state record: ${path}#${legacyId}`);
}
const values = record as Record<string, unknown>;
const expectedKeys = ["importedAt", "projectId", "sourceRelativePath", "status", "workspaceDir"];
if (Object.keys(values).sort().join("\0") !== expectedKeys.join("\0")) {
throw new Error(`invalid legacy import state fields: ${path}#${legacyId}`);
}
if (values.status !== "PENDING" && values.status !== "COMPLETED") {
throw new Error(`invalid legacy import state status: ${path}#${legacyId}`);
}
for (const field of ["projectId", "workspaceDir", "importedAt", "sourceRelativePath"] as const) {
if (typeof values[field] !== "string") throw new Error(`invalid legacy import state ${field}: ${path}#${legacyId}`);
}
if (values.projectId === "" || values.sourceRelativePath === "") {
throw new Error(`invalid legacy import state identity: ${path}#${legacyId}`);
}
if (values.status === "PENDING" && (values.workspaceDir !== "" || values.importedAt !== "")) {
throw new Error(`invalid pending legacy import state: ${path}#${legacyId}`);
}
if (values.status === "COMPLETED" && (values.workspaceDir === "" || values.importedAt === "")) {
throw new Error(`invalid completed legacy import state: ${path}#${legacyId}`);
}
}
async function writeState(path: string, state: LegacyProjectImportState): Promise<void> {
await mkdir(dirname(path), { recursive: true, mode: 0o750 });
const temporary = `${path}.tmp`;
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, path);
}
function isMissing(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+1 -9
View File
@@ -22,7 +22,6 @@ export function buildUnboundChatOnboardingCard(params: {
readonly folders: readonly OnboardingFolderOption[];
readonly projects: readonly OnboardingProjectOption[];
readonly canCreateProject: boolean;
readonly searchQuery?: string | undefined;
}): Record<string, unknown> {
const actions: unknown[] = [];
if (params.canCreateProject) {
@@ -65,14 +64,7 @@ export function buildUnboundChatOnboardingCard(params: {
`这个飞书群还没有绑定项目。`,
``,
`组织: **${escapeMarkdown(params.organizationName)}**`,
...(params.searchQuery === undefined || params.searchQuery === ""
? [`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`]
: [
`项目搜索: **${escapeMarkdown(params.searchQuery)}**`,
params.projects.length === 0
? `没有匹配的未绑定项目。请 @bot 后换一个项目关键词。`
: `请选择匹配项目绑定到本群;如未找到,请 @bot 后换一个更具体的关键词。`,
]),
`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`,
].join("\n"),
},
];
+2 -19
View File
@@ -1070,12 +1070,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role);
const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100);
const projects = await listBindableProjectsForActor({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
searchQuery,
});
const folders = await listCreatableRootFolders(organization.organizationId);
@@ -1088,7 +1086,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
folders,
projects,
canCreateProject,
searchQuery,
}),
sendOptionsForTriggerMessage(msg),
);
@@ -1185,32 +1182,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly searchQuery: string;
}): Promise<readonly OnboardingProjectOption[]> {
const allowed: OnboardingProjectOption[] = [];
let cursor: string | undefined;
while (allowed.length < 5) {
const candidates = await deps.prisma.project.findMany({
where: {
organizationId: input.organizationId,
archivedAt: null,
groupBindings: { none: { archivedAt: null } },
...(input.searchQuery === "" ? {} : {
OR: [
{ name: { contains: input.searchQuery, mode: "insensitive" } },
{ folder: { name: { contains: input.searchQuery, mode: "insensitive" } } },
],
}),
},
select: {
id: true,
name: true,
folder: { select: { name: true } },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
orderBy: { updatedAt: "desc" },
take: 20,
...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }),
});
const allowed: OnboardingProjectOption[] = [];
for (const project of candidates) {
if (!input.isOrgAdmin) {
const decision = await authorizer.can({
@@ -1227,10 +1214,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
});
if (allowed.length >= 5) break;
}
if (candidates.length < 20) break;
cursor = candidates.at(-1)?.id;
if (cursor === undefined) break;
}
return allowed;
}
+1 -5
View File
@@ -27,8 +27,6 @@ export interface CreateOrgAdminProjectInput {
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
readonly sortKey?: string | undefined;
/** Stable internal identifier for resumable imports; ordinary callers must omit it. */
readonly projectId?: string | undefined;
}
export interface CreateFeishuChatProjectInput {
@@ -149,7 +147,6 @@ export async function createProjectFromOrgAdmin(
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
sortKey: input.sortKey,
projectId: input.projectId,
chatId: undefined,
});
}
@@ -294,7 +291,6 @@ async function createManagedProject(
readonly workspaceRoot: string;
readonly folderId: string | undefined;
readonly sortKey?: string | undefined;
readonly projectId?: string | undefined;
readonly chatId: string | undefined;
},
): Promise<ProjectOnboardingResult> {
@@ -305,7 +301,7 @@ async function createManagedProject(
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status);
const projectId = input.projectId ?? createProjectId();
const projectId = createProjectId();
const workspaceDir = projectWorkspaceDir({
workspaceRoot: input.workspaceRoot,
organizationSlug: organization.slug,
@@ -1,185 +0,0 @@
import { mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { importLegacyProjects } from "../../src/deployment/legacyProjectImport.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const temporaryRoots: string[] = [];
describe("legacy teaching-material project import", () => {
beforeEach(async () => {
await resetDb();
await prisma.user.create({
data: {
id: "legacy-import-owner",
feishuOpenId: "ou_legacy_owner",
displayName: "Legacy Import Owner",
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
},
});
});
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
afterAll(async () => prisma.$disconnect());
it("imports each legacy project as an unbound resumable project under its old folder path", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "物理", "M-243-牛顿力学");
await mkdir(join(projectSource, "workspace", "chapters"), { recursive: true });
await mkdir(join(projectSource, "workspace", ".claude"), { recursive: true });
await mkdir(join(projectSource, "workspace", "chapters", ".cph"), { recursive: true });
await mkdir(join(projectSource, "_raw"), { recursive: true });
await writeFile(join(projectSource, "workspace", "project.toml"), "title = \"牛顿力学\"\n");
await writeFile(join(projectSource, "workspace", "chapters", "lesson.typ"), "= 牛顿第二定律\n");
await writeFile(join(projectSource, "workspace", ".claude", "session.json"), "{}\n");
await writeFile(join(projectSource, "workspace", "chapters", ".cph", "runtime.json"), "{}\n");
await writeFile(join(projectSource, "project.json"), "{\"id\":\"M-243\"}\n");
await writeFile(join(projectSource, "_raw", "source.txt"), "legacy source\n");
const stateFile = join(workspaceRoot, "migration-state", "state.json");
const manifest = [{
legacyId: "M-243",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/M-243-牛顿力学",
}];
const first = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
const imported = first.projects["M-243"];
expect(imported).toBeDefined();
if (imported === undefined) throw new Error("missing imported project state");
const project = await prisma.project.findUniqueOrThrow({
where: { id: imported.projectId },
include: { folder: { include: { parent: true } }, groupBindings: true },
});
expect(project.name).toBe("牛顿力学");
expect(project.folder?.name).toBe("物理");
expect(project.folder?.parent?.name).toBe("旧教学资产");
expect(project.groupBindings).toEqual([]);
await expect(readFile(join(imported.workspaceDir, "chapters", "lesson.typ"), "utf8"))
.resolves.toBe("= 牛顿第二定律\n");
await expect(readFile(join(imported.workspaceDir, ".claude", "session.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, "chapters", ".cph", "runtime.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "project.json"), "utf8"))
.resolves.toContain("M-243");
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "raw", "source.txt"), "utf8"))
.resolves.toBe("legacy source\n");
await unlink(stateFile);
const second = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
expect(second.projects["M-243"]?.projectId).toBe(imported.projectId);
await expect(prisma.project.count({ where: { organizationId: DEFAULT_ORG_ID } })).resolves.toBe(1);
expect(JSON.parse(await readFile(stateFile, "utf8"))).toMatchObject({
version: 1,
projects: { "M-243": { projectId: imported.projectId } },
});
await expect(prisma.auditEntry.count({
where: { projectId: imported.projectId, action: "legacy_project.imported" },
})).resolves.toBe(1);
await writeFile(join(imported.workspaceDir, ".legacy-source", "migration.json"), "not json\n");
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
})).rejects.toThrow(/invalid legacy completion marker/);
await expect(prisma.project.count({ where: { id: imported.projectId } })).resolves.toBe(1);
});
it("rejects symlinks instead of importing paths outside the staged project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-symlink-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "legacy");
await mkdir(join(projectSource, "workspace"), { recursive: true });
await writeFile(join(projectSource, "project.json"), "{}\n");
await symlink("/etc/passwd", join(projectSource, "workspace", "outside"));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile: join(workspaceRoot, "state.json"),
projects: [{ legacyId: "symlink", name: "Symlink", folderPath: [], sourceRelativePath: "legacy" }],
})).rejects.toThrow(/rejects symbolic link/);
await expect(prisma.project.count()).resolves.toBe(0);
});
it("rejects a manifest path that escapes the staged source root", async () => {
const parent = await temporaryRoot("cph-legacy-escape-");
const sourceRoot = join(parent, "source");
const outside = join(parent, "outside");
await mkdir(sourceRoot);
await mkdir(outside);
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot: await temporaryRoot("cph-legacy-target-"),
sourceRoot,
stateFile: join(parent, "state.json"),
projects: [{
legacyId: "escape",
name: "Escape",
folderPath: [],
sourceRelativePath: "../outside",
}],
})).rejects.toThrow(/escapes source root/);
});
it("rejects malformed resume state before creating a project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-state-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-state-target-");
const stateFile = join(workspaceRoot, "state.json");
await writeFile(stateFile, JSON.stringify({ version: 1, projects: { broken: { status: "MAYBE" } } }));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: [],
})).rejects.toThrow(/invalid legacy import state fields/);
await expect(prisma.project.count()).resolves.toBe(0);
});
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
temporaryRoots.push(root);
return root;
}
-94
View File
@@ -361,100 +361,6 @@ describe("trigger full lifecycle (integration)", () => {
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
});
it("uses unbound-chat mention text to search bindable projects", async () => {
await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
const folder = await prisma.folder.create({
data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" },
});
await prisma.project.createMany({
data: [
{
id: "p-search-newton",
organizationId: DEFAULT_ORG_ID,
folderId: folder.id,
name: "牛顿力学专题",
workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"),
},
{
id: "p-search-optics",
organizationId: DEFAULT_ORG_ID,
folderId: folder.id,
name: "几何光学专题",
workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"),
},
],
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: await tempWorkspaceRoot(),
});
await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "p-search-newton",
},
});
expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({
project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }),
}));
expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿");
expect(runAgentCalls).toHaveLength(0);
});
it("continues searching past unauthorized matches for a manageable project", async () => {
await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
const workspaceRoot = await tempWorkspaceRoot();
await prisma.project.createMany({
data: [
...Array.from({ length: 20 }, (_, index) => ({
id: `z-search-denied-${String(index).padStart(2, "0")}`,
organizationId: DEFAULT_ORG_ID,
name: `迁移项目 ${index}`,
workspaceDir: join(workspaceRoot, `denied-${index}`),
})),
{
id: "a-search-allowed",
organizationId: DEFAULT_ORG_ID,
name: "迁移项目 可管理",
workspaceDir: join(workspaceRoot, "allowed"),
},
],
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "a-search-allowed",
principalType: "USER",
principalId: "ou_onboard_page",
role: "MANAGE",
},
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: workspaceRoot,
});
await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "a-search-allowed",
},
});
});
it("batches quick text messages from the same chat and sender into one run", async () => {
await seedProject("proj-1b", "chat-1b");
const trigger = makeTriggerHandler({
@@ -1,63 +0,0 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
const execute = promisify(execFile);
const temporaryRoots: string[] = [];
describe("legacy project manifest builder", () => {
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
it("stops at a project root and excludes trash projects", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-"));
temporaryRoots.push(root);
const projectRoot = join(root, "物理", "legacy__牛顿力学");
await mkdir(join(projectRoot, "workspace", "nested"), { recursive: true });
await mkdir(join(projectRoot, "_raw"), { recursive: true });
await mkdir(join(root, ".trash", "deleted"), { recursive: true });
await writeFile(join(projectRoot, "project.json"), JSON.stringify({
id: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
}));
await writeFile(join(projectRoot, "workspace", "nested", "project.json"), "not metadata");
await writeFile(join(projectRoot, "_raw", "project.json"), "not metadata");
await writeFile(join(root, ".trash", "deleted", "project.json"), JSON.stringify({
id: "deleted",
name: "Deleted",
}));
const { stdout } = await execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
]);
expect(JSON.parse(stdout)).toEqual([{
legacyId: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/legacy__牛顿力学",
}]);
});
it("reports untracked symlinked entries instead of silently omitting them", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-link-"));
temporaryRoots.push(root);
await symlink("/tmp", join(root, "linked-project"));
const result = await execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
]);
expect(result.stderr).toContain("skip untracked symbolic link");
expect(JSON.parse(result.stdout)).toEqual([]);
});
});
+1 -1
View File
@@ -49,7 +49,7 @@ agent 不得用预训练先验脑补本领域(领域很新,无先验);prose 是
### 命名
- **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Run``Spec.Courseware.Validity`
- **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Agent.Run``Spec.Courseware.Validity`
- **类型**:PascalCase。
- **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…``ValidTransition``Can…`
- **文件粒度**:原则上"一个带独立不变式的概念一个文件"。
+1 -1
View File
@@ -16,6 +16,6 @@ import Spec.Courseware.Open
- **`Check`** checker :`Severity` + 6 + ** lesson = error
**( + `Oracle` );线 5 compile
- **`Open`** ( OPEN, surface ): `QuestionBank`
- **`Open`** OPEN ( OPEN, surface): `QuestionBank`
`Course`
-/
+2 -2
View File
@@ -6,7 +6,7 @@ import Spec.Courseware.Export.Render
"站在 Lean 位置" rule-based checker,(ADR-0010, ADR-0012
) lesson ,****(`DiagKind`)****(`Severity`)
:(); 7 ,"**合法 lesson = 无
(); 7 ,"**合法 lesson = 无
error **"建成判定(ADR-0005 deferred 的""的回填);对**模型外设施**
(typst schema )** + `Oracle` **
"存在这条诊断、什么意思、什么级别",, Lean ( typst
@@ -50,7 +50,7 @@ inductive DiagKind where
/-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯
`renderIgnored` `warning`**ADR-0005 "缺渲染 ⇒ warning,不阻断导出"
使"哪类阻断"( `DiagCode` ) -/
使"哪类阻断"( `DiagCode` ) -/
def DiagKind.severity : DiagKind Severity
| .partPathMissing => .error
| .unknownKind => .error
+2 -2
View File
@@ -5,8 +5,8 @@ import Spec.Courseware.Check.Diagnostic
checker `check` ****,;`compile` ****
(,
,)**** Lean( 5 ):**
**:`load``cph-model`;`structural`part / kind;
,)**** Lean(:**
**):`load``cph-model`;`structural`part / kind;
`schema``cph-schema`;`compile``cph-typst`();`coverage``renderIgnored`
-/
+1 -1
View File
@@ -2,7 +2,7 @@
# Artifact export target (ADR-0009 / 0011)
ADR-0009: export target ** build**,****ADR-0011
:** ADT**"产物到底指什么"( / )
:** ADT**"产物到底指什么"( / )
, + doc,/glob `String`
doc (),
-/
+3 -3
View File
@@ -4,7 +4,7 @@ import Spec.Courseware.Export.Artifact
/-!
# Render export target = artifact + typed steps(ADR-0009 / 0011)
ADR-0009:export target build, `Artifact`ADR-0011 build
ADR-0009:export target build, `Artifact`ADR-0011 build
****: target `artifact` + ** typed step**
- `typstCompile template` ****( `exports/student.typ`)
@@ -20,7 +20,7 @@ ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR
**shell step (ADR-0013)** `shell` :****,
`run` shell****,(
),:
),:
1. **opt-in by construction** **** build shell target ,
`check` `check` (lesson ),
2. **** shell step 退 **build-**, lesson ;
@@ -46,7 +46,7 @@ namespace Spec.Courseware
variable (P : Primitives)
/-- 一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。MVP 仅一个 `typstCompile`;
`steps` list FileTree / build shell
`steps` list FileTree / build shell
(, ADR-0011 OPEN) -/
inductive Step where
/-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由:
+1 -1
View File
@@ -7,7 +7,7 @@ import Spec.Courseware.Model.Info
/-!
# Courseware.Model
(`Primitives`)(`RichContent`)(`Element`)
(`Primitives`)(`RichContent`)(`Element`)
(`Lesson`)(`Info`:canonical author vs `RawInfo` )
ADR-0005 / 0006 / 0008
-/
+1 -1
View File
@@ -3,7 +3,7 @@ import Spec.Courseware.Model.Primitives
/-!
# Element
ADR-0005:element = kind + kind schema
ADR-0005:element = kind + kind schema
,使"数据必须匹配其 kind"
-/
+2 -2
View File
@@ -5,7 +5,7 @@
**:(), canonical author
****,
:on-disk ****()****
:on-disk ****()****
`author = ""`, `author = ["", ""]`"字符串或数组"**
**:`RawInfo` canonical `Info`,canonical
`List String`,raw `Info`(canonical) `RawInfo`
@@ -24,7 +24,7 @@ inductive RawAuthor where
| many (names : List String)
/-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组
"canonical 接收端始终是 `List String`" -/
canonical `List String` -/
def RawAuthor.normalize : RawAuthor List String
| .one n => [n]
| .many ns => ns
+6 -6
View File
@@ -1,26 +1,26 @@
/-!
# Primitives Courseware
# Primitives Courseware
(ADR-0005):element kind kind
schema export target `Primitives`,
element / lesson / ****,****
: PINNED( schema ADR-0006 ) Lean
: PINNED( schema ADR-0006 ) Lean
JSON Schema / typst , Lean,
prose `Courseware.RichContent`
`Courseware.RichContent`
-/
namespace Spec.Courseware
/-- Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 -/
structure Primitives where
/-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。刻意用抽象
/-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。用抽象
`inductive`:ADR-0005 kind (stdlib + ),
****( `RunState` "尚未封闭") -/
KindId : Type
/-- 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED`
ADR-0006,) kind :`ElementData k` "符合 `k` schema 的
"。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源) ADR-0006
, JSON/typst , Lean;"数据符合
"。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源) ADR-0006
, JSON/typst , Lean;"数据符合
kind schema"这条关系,故此处仍是抽象类型。 -/
ElementData : KindId Type
/-- export target 标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次
+4 -4
View File
@@ -1,5 +1,5 @@
/-!
# RichContent (ADR-0006 prose )
# RichContent (ADR-0006 )
ADR-0006:element schema "叶子" `content` ,****,
**format** (ADR-0015) format:
@@ -13,7 +13,7 @@ ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**
,****; import + `@package`()markdown format
typst ,( markdown step , `Export/Render`)
prose + :typst `Content`/`Module` JSON Schema format
+ :typst `Content`/`Module` JSON Schema format
, Lean,"富内容由一个虚拟路径定位"+"叶子带 format"
-/
@@ -33,8 +33,8 @@ inductive ContentFormat where
| markdown
/-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个
**format**(`PINNED`, ADR-0015)**** `Content`();
"富内容经由一个 `VPath` 定位 + 带 format", `Primitives.ElementData` `content` -/
**format**(`PINNED`, ADR-0015) `Content`();
"富内容经由一个 `VPath` 定位 + 带 format", `Primitives.ElementData` `content` -/
structure RichContentRef where
/-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/
vpath : VPath
+2 -2
View File
@@ -2,8 +2,8 @@ import Spec.Courseware.Open.QuestionBank
import Spec.Courseware.Open.Course
/-!
# Courseware.Open ( OPEN)
# Courseware.Open OPEN ()
element (`QuestionBank`)(`Course`) surface
, 2 , ADR
OPEN , ADR
-/
+1 -1
View File
@@ -5,6 +5,6 @@ ADR-0005:工程文件的粒度是**单节课**;course / 单元**不是**工程
****"编排":( )?lesson
? lesson ()? `OPEN`
2 **** `Course := List Lesson`(
`Course := List Lesson`(
"扁平有序、无层级") surface: ADR
-/
+1 -1
View File
@@ -5,6 +5,6 @@
,lesson ** element **,
"纯引用可能不够"element / lesson / ?
`OPEN` 2 **** `QuestionRef`
`OPEN` `QuestionRef`
, surface ADR
-/
+17 -7
View File
@@ -1,10 +1,10 @@
/-!
# Prelude System
(runsessionprincipalchatplatform identity/audit)
(UUID / principal ),, opaque
`Identifiers`,System "锁 owner 是哪个 run"
****,
(runsessionprincipalchatplatform identity/audit)
(UUID / principal ),, opaque
`Identifiers`,System "锁 owner 是哪个 run"
,
-/
namespace Spec.System
@@ -15,23 +15,33 @@ structure Identifiers where
ProjectId : Type
/-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/
OrganizationId : Type
/-- 租户层用户标识(`OPEN` 表示;独立实体,非飞书身份派生;见 `Hierarchy.User`)。 -/
UserId : Type
/-- 飞书用户 open_id(`OPEN` 表示;单应用作用域内唯一)。 -/
FeishuOpenId : Type
/-- 飞书 user_id(`OPEN` 表示;租户内唯一,换 app 不变)。 -/
FeishuUserId : Type
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
FeishuAppId : Type
/-- 飞书 app_secret 信封引用(`OPEN` 表示;ADR-0024)。 -/
FeishuAppSecretRef : Type
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
TeamId : Type
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
FolderId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@Claude` , provider) -/
ADR-0017;`@bot` , provider) -/
RunId : Type
/-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次
session provider/model; model session, session ADR-0003
/, session provider/model run , ADR-0002) -/
SessionId : Type
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/
, plumbing, opaque ) -/
, opaque ) -/
Principal : Type
/-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、
ADR-0004 `feishu_chat` principal `Principal`
principal OPEN ,"chat 是 principal 的哪种子型") -/
principal OPEN,"chat 是 principal 的哪种子型") -/
ChatId : Type
/-- 平台管理员身份标识(`OPEN` 表示;ADR-0023,不复用客户 `User` 标识)。 -/
PlatformIdentityId : Type
+22 -14
View File
@@ -1,41 +1,49 @@
import Spec.System.Hierarchy
import Spec.System.ProjectGroup
import Spec.System.Organization
import Spec.System.User
import Spec.System.Connections
import Spec.System.ProjectWorkspace
import Spec.System.Capacity
import Spec.System.PlatformAdministration
import Spec.System.Run
import Spec.System.Agent.Run
import Spec.System.Agent.AgentRole
import Spec.System.Agent.Memory
import Spec.System.Agent.AgentSurface
import Spec.System.Lock
import Spec.System.Memory
import Spec.System.AgentSurface
import Spec.System.Permission
import Spec.System.PermissionGrant
import Spec.System.Audit
/-!
# System Hub
:AgentRunlikec4
(`docs/architecture/likec4/`)****; likec4
****:
:AgentRun
likec4 ;:
- `Hierarchy` :
- `User` (; `OPEN`)
- `Connections` :() + /
- `ProjectGroup` project 1:1 (ADR-0001);,
- `Organization` SaaS tenant root(ADR-0020);project/team ,TEAM grant org;
connection secret 使 fail-closed resolver(ADR-0024)
- `Organization` SaaS (ADR-0020);project/team ,TEAM grant org;
connection secret fail-closed resolver(ADR-0024);
owner/admin/member(`OrganizationRole`)()
- `ProjectWorkspace` org project explorer:folder ,project
(ADR-0021)
- `Capacity` platform ceiling org policy admission request
(ADR-0022)
- `PlatformAdministration` /
fail-closed 线 emergency grant(ADR-0023)
- `AgentRole` org-scoped agent + (ADR-0017/0018)
- `Run` AgentRun ( OPEN)
- `Lock` owner=run(ADR-0002),"持锁者必为非终止 run"
- `Memory` :(ADR-0003)+ MCP run/project
- `Memory` :(ADR-0003)+ MCP run/project
- `AgentSurface` agent run (ADR-0018); Lock
Lock ,Surface OPEN
- `Permission` readeditmanage ;force-release
- `PermissionGrant` grant(resource×principal×role) settings( policy )
(ADR-0004);role-capability settings-policy OPEN
- `Audit` customer Project/Run ( plumbing,OPEN);Platform
Audit `PlatformAdministration`
- `Permission` readeditmanage ;force-release
- `PermissionGrant` grant(resource×principal×role) settings( policy )
(ADR-0004); OPEN
- `Audit` customer Project/Run ( OPEN);Platform Audit
`PlatformAdministration`
`Spec.Prelude`:ADR-0001..0004, 0018, 0020..0024
-/
+60
View File
@@ -0,0 +1,60 @@
import Spec.Prelude
/-!
# AgentRole Agent (ADR-0017, ADR-0018)
AgentRole org-scoped :system prompttool allowlistdefault model
skill CLI ,
Role (model/prompt/tools/skill ), role active sessions
run provider context
label/(ADR-0017)
Run role skill run-scoped ,
run (ADR-0018)Skill org-scoped; `OPEN`
-/
namespace Spec.System
variable (I : Identifiers)
/-- Agent 角色(`PINNED`, org-scoped, ADR-0017)。 -/
structure AgentRole where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 斜杠命令名(`OPEN` 表示;如 /draft)。 -/
roleId : String
/-- system prompt(`PINNED`)。 -/
systemPrompt : String
/-- tool allowlist(`PINNED`;tool 标识集合 `OPEN`)。 -/
tools : List String
/-- 默认 model(`PINNED`;model ID 表示 `OPEN`)。 -/
defaultModel : String
/-- Agent 技能(`PINNED`, org-scoped, ADR-0017/0018)。 -/
structure AgentSkill where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 名称(`PINNED`)。 -/
name : String
/-- 版本(`PINNED`)。 -/
version : String
/-- 内容摘要(`PINNED`;SHA-256 content-addressed)。 -/
contentDigest : String
/-- 描述(`OPEN`)。 -/
description : String
/-- Role-Skill 绑定(`PINNED`, ADR-0017)。一个 role 可绑定零或多个 skill。 -/
structure AgentRoleSkillBinding where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 绑定的 role(`PINNED`)。 -/
roleId : String
/-- skill 名称(`PINNED`)。 -/
skillName : String
/-- skill 版本(`PINNED`)。 -/
skillVersion : String
/-- 排序(`PINNED`)。 -/
sortOrder : Nat
end Spec.System
+40
View File
@@ -0,0 +1,40 @@
import Spec.Prelude
import Spec.System.Agent.Run
/-!
# AgentSurface Agent (ADR-0018)
Agent run , run project
(ADR-0007),
`Lock`(ADR-0002):Lock (),Surface ()
run × project
shell ()
OS SDK ,`OPEN`(ADR-0018)
-/
namespace Spec.System
variable (I : Identifiers) (Path : Type)
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
; `Authorized` -/
structure AgentFileOp where
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
run : I.RunId
/-- 操作目标路径(`PINNED`, ADR-0018)。 -/
path : Path
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
(`PINNED` , ADR-0018)`runWorkspace` `pathWithin`
( `OPEN`);"操作路径必须以 run 的工作区为根", agent
宿 -/
def AgentFileOp.Authorized
(op : AgentFileOp I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace op.run = some w pathWithin op.path w
end Spec.System
@@ -3,13 +3,13 @@ import Spec.Prelude
/-!
# Memory :(ADR-0003)
ADR-0003:Hub ****,;Claude
API ****(ADR , OPENADR
"例如",), likec4 :**MCP
run/project ,Claude chat id**(ADR-0003 Consequences )
ADR-0003:Hub ,;Claude
API (ADR , OPEN),
:MCP run/project ,Claude chat id
(ADR-0003 Consequences )
"chat id 与 project 绑定" `ProjectGroup.GroupBinding`(ADR-0001),
,(线)
(线)
-/
namespace Spec.System
@@ -17,9 +17,8 @@ namespace Spec.System
variable (I : Identifiers)
variable (MessageId CardId : Type)
/-- 上下文锚点(`PINNED` 类别, ADR-0003 列定;**枚举完整性 `OPEN`**——ADR 是"例如"式
, surface,) Hub ,
-/
/-- 上下文锚点(`PINNED` 类别, ADR-0003;枚举完整性 `OPEN`——ADR 是"例如"式列举,
surface) Hub , -/
inductive Anchor where
/-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/
| triggerMessage : MessageId Anchor
@@ -35,13 +34,11 @@ MCP tools to read … through Feishu APIs")。 -/
structure McpReadRequest where
/-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/
run : I.RunId
/-- 请求读取的 chat(是否允许越界由下方 `Authorized` 钉死:不允许)。 -/
/-- 请求读取的 chat(授权由下方 `Authorized` 约束:不允许越界)。 -/
chat : I.ChatId
/-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式,
ADR-0003 Consequences "MCP tools must authorize by run/project context; Claude cannot
pass arbitrary chat ids")。`runProject`/`boundChat` 由平台提供(表示 `OPEN`);本谓词只
"chat 必须匹配 run 的 project 绑定", Claude chat id -/
ADR-0003)"chat 必须匹配 runproject 绑定", Claude chat id -/
def McpReadRequest.Authorized
(req : McpReadRequest I)
(runProject : I.RunId Option I.ProjectId)
@@ -1,16 +1,16 @@
/-!
# Run AgentRun
`@Claude` `AgentRun`(ADR-0001),(ADR-0002)
ADR / likec4 ,******
**( Lock ),
`@bot` `AgentRun`(ADR-0001),(ADR-0002)
ADR ,( Lock
),
-/
namespace Spec.System
/-- AgentRun 运行状态(状态名 `PINNED`, ADR-0001..0003, ADR-0022 + likec4;
** `OPEN`**"状态恰好这些";( pending)
surface,) `RunState.Terminal` -/
/-- AgentRun 运行状态(状态名 `PINNED`, ADR-0001..0003, ADR-0022;完整性 `OPEN`
ADR "状态就是这些";( pending) surface)
`RunState.Terminal` -/
inductive RunState where
| active
| waitingForUser
-44
View File
@@ -1,44 +0,0 @@
import Spec.Prelude
import Spec.System.Run
/-!
# AgentSurface Agent (ADR-0018)
ADR-0001/0002/0004 "协作治理" `triggerAgent`: run
agent ADR / ADR-0017
Claude Code SDK `bypassPermissions` + Read/Write/Bash/Glob/Grep,
agent shell 宿****:spec ADR
:agent run , run project
(ADR-0007:; ADR "agent 操作落在该树内")
, `Lock`(ADR-0002):Lock ****(),Surface
****() run × project
shell (),****
OS (bubblewrap/)SDK ,`OPEN`
(ADR-0018),
-/
namespace Spec.System
variable (I : Identifiers) (Path : Type)
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
; `Authorized` -/
structure AgentFileOp where
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
run : I.RunId
/-- 操作目标路径(`PINNED` 字段, ADR-0018)。 -/
path : Path
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
(`PINNED` , ADR-0018)`runWorkspace` `pathWithin`
( `OPEN`"在内" plumbing,);
"操作路径必须以 run 的工作区为根", agent 宿 -/
def AgentFileOp.Authorized
(op : AgentFileOp I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace op.run = some w pathWithin op.path w
end Spec.System
+6 -9
View File
@@ -1,21 +1,18 @@
import Spec.Prelude
/-!
# Audit Project/Run ()
# Audit Project/Run
likec4 `AuditLog` (`AgentRun -> AuditLog 'records lifecycle events'`),
****( schema) ADR /
, plumbing:
"审计以 run 为主体记录其生命周期事件", `OPEN`(
:)
( schema) ADR ,
"审计以 run 为主体记录其生命周期事件"
, `OPEN`ADR-0023 Platform Audit ,
`Spec.System.PlatformAdministration`,
-/
namespace Spec.System
/-- 审计条目的最小骨架(关系 `PINNED` / 内容 `OPEN`, likec4)。只承诺"一条审计记录
run";事件类型、时间、actor、详情等字段 `OPEN`,待真实分歧点出现时由
ADR ADR-0023 Platform Audit fail-closed ,
`Spec.System.PlatformAdministration`, -/
run";事件类型、时间、actor、详情等字段 `OPEN`。 -/
structure AuditEntry (I : Identifiers) where
/-- 该审计条目所属的 run(`PINNED` 关系, likec4)。 -/
run : I.RunId
+2 -2
View File
@@ -4,8 +4,8 @@ import Spec.Prelude
# Capacity SaaS capacity admission and abuse controls (ADR-0022)
, Organization
ADR-0022 admission;
, `OPEN`,
ADR-0022 admission;
, `OPEN`
-/
namespace Spec.System
+24
View File
@@ -0,0 +1,24 @@
import Spec.System.Connections.Prelude
import Spec.System.Connections.Feishu
/-!
# Connections
/ `Connections.Prelude`;
`Connections.Feishu`
-/
namespace Spec.System
/-- 组织连接绑定(`PINNED`)。 -/
inductive ConnectionBinding (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuAppBinding I ConnectionBinding I
/-- 用户连接信息(`PINNED`)。 -/
inductive ConnectionProfile (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuProfile I ConnectionProfile I
end Spec.System
+31
View File
@@ -0,0 +1,31 @@
import Spec.Prelude
/-!
# Feishu
(1:1) API
-/
namespace Spec.System
variable (I : Identifiers)
/-- 组织的飞书应用绑定(`PINNED`, 1:1)。 -/
structure FeishuAppBinding where
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
appId : I.FeishuAppId
/-- app_secret 信封引用(`PINNED`, ADR-0024)。 -/
appSecretEnvelope : I.FeishuAppSecretRef
/-- 用户的飞书信息(`PINNED`)。 -/
structure FeishuProfile where
/-- 应用内身份(`OPEN`);调 API 的直接句柄,换应用即变。 -/
openId : I.FeishuOpenId
/-- 租户内身份(`OPEN`);换应用不变,比 open_id 稳定。 -/
userId : I.FeishuUserId
/-- 显示名(`OPEN`)。 -/
name : Option String
/-- 头像 URL(`OPEN`)。 -/
avatarUrl : Option String
end Spec.System
+15
View File
@@ -0,0 +1,15 @@
/-!
# Connections.Prelude
/ IdP ()
provider connection , `Spec.System.Organization`
-/
namespace Spec.System
/-- 连接提供商(`PINNED`;当前仅飞书,未来可扩展钉钉/企微)。 -/
inductive ConnectionProvider where
/-- 飞书(`PINNED`)。 -/
| feishu
end Spec.System
+46
View File
@@ -0,0 +1,46 @@
import Spec.Prelude
import Spec.System.Connections
/-!
# Hierarchy
: ()
- ****(Platform): SaaS
- ****(Organization): SaaS
- ****(User):
****: "管理员"
-/
namespace Spec.System
variable (I : Identifiers)
/-- 平台(`PINNED`, SaaS 提供方)。只有一个,独立于组织。管理面见 `PlatformAdministration`(ADR-0023)。 -/
structure Platform where
/-- 平台自有飞书应用(`PINNED`, ADR-0023)。 -/
application : I.PlatformFeishuApplicationId
/-- 组织(`PINNED`, ADR-0020)。project/team 必须归属且仅归属一个 org。
`Connections`/tenancy/ `Spec.System.Organization` -/
structure Organization where
/-- 组织标识(`OPEN` 表示)。 -/
id : I.OrganizationId
/-- 外部连接(`PINNED`)。 -/
connections : List (ConnectionBinding I)
/-- 用户(`PINNED`, 租户层独立实体)。必属一个组织。外部连接见 `Connections`。 -/
structure User where
/-- 用户标识(`OPEN` 表示;组织内唯一,不可改;登录用)。 -/
id : I.UserId
/-- 所属组织(`PINNED`, ADR-0020)。 -/
organization : I.OrganizationId
/-- 显示名(`PINNED`, 可改)。 -/
displayName : String
/-- 密码哈希(`OPEN` 表示;id + 密码登录)。 -/
passwordHash : String
/-- 外部连接(`PINNED`)。 -/
connections : List (ConnectionProfile I)
end Spec.System
+10 -13
View File
@@ -1,35 +1,32 @@
import Spec.Prelude
import Spec.System.Run
import Spec.System.Agent.Run
/-!
# Lock
ADR-0002 : Claude , **owner `AgentRun`**
( teacher / chat / session),
likec4 ** run**
ADR-0002: agent , owner `AgentRun`(
teacher / chat / session) run
-/
namespace Spec.System
variable (I : Identifiers)
/-- 项目级锁(`PINNED`, ADR-0002)。`owner : RunId`(非 SessionId/Principal)从类型
"lock owner = run_id": session / teacher -/
/-- 项目级锁(`PINNED`, ADR-0002)。`owner : RunId`从类型上编码"lock owner = run_id":
session / teacher -/
structure ProjectAgentLock where
/-- 作用域:项目级(`PINNED`, ADR-0002 `scope = project_id`)。 -/
/-- 作用域:项目级(`PINNED`, ADR-0002)。 -/
scope : I.ProjectId
/-- 持有者:一个 run(`PINNED`, ADR-0002 `owner = run_id`)。 -/
/-- 持有者:一个 run(`PINNED`, ADR-0002)。 -/
owner : I.RunId
/-- 锁表:每项目当前持锁 run(`PINNED` 排他性, ADR-0002)。`ProjectId → Option RunId`
**** owner -/
owner -/
def LockTable := I.ProjectId Option I.RunId
/-- 锁表良构:**持锁者必为非终止 run**(`PINNED` 平台核心不变式, ADR-0002)。
/-- 锁表良构:持锁者必为非终止 run(`PINNED`, ADR-0002)。
"锁在 run 终止时释放": `p` `r` , `r`
Lock Run likec4 "run owns lock while running","终止即
"这个约束;它正是契约相对结构图的增量。 -/
`p` `r` , `r` run -/
def LockTable.WellFormed
(lt : LockTable I) (statusOf : I.RunId RunState) : Prop :=
p r, lt p = some r ¬ (statusOf r).Terminal
+40 -18
View File
@@ -1,18 +1,12 @@
import Spec.Prelude
/-!
# Organization SaaS tenant root (ADR-0020, ADR-0024)
# Organization SaaS (ADR-0020, ADR-0024)
ADR-0020:Hub SaaS ,`Organization` tenant root
`Project` `Team` organization;team project
organization (platform staff / break-glass / )
project `read/edit/manage` ,
:tenant root project/team team grant
org, model provider connection
provider `OPEN`;// ADR-0023
`Spec.System.PlatformAdministration`
writer authority fail-closed resolver ADR-0024
project/team org;teamproject grant org
`Hierarchy.Organization`; `Spec.System.User`;
`PlatformAdministration`(ADR-0023) ADR-0024Agent
`Spec.System.Agent.AgentRole`
-/
namespace Spec.System
@@ -41,19 +35,17 @@ structure TeamProjectGrantScope where
project : I.ProjectId
/-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/
team : I.TeamId
/-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization
(`PINNED`, ADR-0020)`projectOrg`/`teamOrg` ( `OPEN`);
org team grant -/
(`PINNED`, ADR-0020)`projectOrg`/`teamOrg` ( `OPEN`); org
team grant -/
def TeamProjectGrantScope.WellScoped
(grant : TeamProjectGrantScope I)
(projectOrg : I.ProjectId Option I.OrganizationId)
(teamOrg : I.TeamId Option I.OrganizationId) : Prop :=
o, projectOrg grant.project = some o teamOrg grant.team = some o
/-- Organization 的 model provider 凭据归属模式(`PINNED`, ADR-0021):BYOK 由 org
;platform-managed org
org process-global provider key `OPEN` -/
/- BYOK 由组织所有者/管理员管理;platform-managed 由平台管理员管理。两种模式都不允许
org process-global key `OPEN` -/
inductive ProviderCredentialMode where
| byok
| platformManaged
@@ -78,7 +70,7 @@ inductive OrganizationConnectionStatus where
/-- Organization secret version 的信封绑定上下文(`PINNED`, ADR-0024):认证附加数据必须
organizationconnectionsecret version purpose, org
connection plumbing, opaque -/
connection , opaque -/
structure OrganizationSecretBinding
(OrganizationId ConnectionId SecretVersionId Purpose : Type) where
/-- secret 所属 organization(`PINNED`, ADR-0024)。 -/
@@ -104,4 +96,34 @@ def OrganizationSecretResolvable
organizationActive && connectionActive &&
(binding.organization == requestedOrganization) && authenticatedEnvelope
/-- 组织成员角色(`PINNED` 封闭三档)。与项目层 `Role`、平台层 `PlatformRole` 互不相交。 -/
inductive OrganizationRole where
/-- 组织所有者(`PINNED`):bootstrap,独家管 owner 群体,受最后所有者保护。 -/
| owner
/-- 组织管理员(`PINNED`):管成员/policy/BYOK,不能管 owner 群体。 -/
| admin
/-- 组织成员(`PINNED`):普通成员。 -/
| member
/-- 组织成员关系(`PINNED`)。 -/
structure OrganizationMembership where
/-- 成员(`PINNED`;独立实体,见 `Hierarchy.User`)。 -/
user : I.UserId
/-- 组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 角色(`PINNED`)。 -/
role : OrganizationRole
/-- 只有组织所有者能管 owner 群体(`PINNED`)。 -/
def CanManageOwnerGroup (actorRole : OrganizationRole) : Prop :=
actorRole = .owner
/-- 最后所有者保护(`PINNED`):撤销 owner 时同 org 必须还有一个不同的 owner。 -/
def LastOwnerProtected
(target : OrganizationMembership I)
(otherOwnersInOrg : List I.UserId) : Prop :=
target.role .owner
other, other otherOwnersInOrg other target.user
end Spec.System
+1 -2
View File
@@ -5,8 +5,7 @@ import Spec.Prelude
ADR-0004:"飞书云文档式"grant(`resource + principal + role`) settings
;role `read / edit / manage`, **read edit manage** ;
**admin-only**, role "高 role 含低
role "的单调性钉死。
**admin-only**, role
-/
namespace Spec.System
+9 -12
View File
@@ -4,16 +4,14 @@ import Spec.System.Permission
/-!
# PermissionGrant (ADR-0004)
ADR-0004 "飞书云文档式":**grant**(`resource × principal × role`) **settings**
( policy );role "谁能"(, `Permission`),settings "此资源
"(策略)。本模块把 grant/settings 的结构钉死——`Permission` 已落 role 能
,"授权如何挂到资源/主体上"
ADR-0004 "飞书云文档式":grant(`resource × principal × role`) settings
( policy );role ( `Permission`),settings "此资源是否
"
principal (user/chat/department/) policy `OPEN`(ADR ,
)**role-capability settings-policy ** `OPEN`
ADR-0004 ,(AND?settings role?),
surface,ADR-0020 TEAM principal PROJECT resource
organization; tenant well-scopedness `Spec.System.Organization`
principal (user/chat/department/) policy `OPEN`role-capability
settings-policy `OPEN`ADR-0004 ,
ADR-0020 TEAM principal PROJECT resource
organization, `Spec.System.Organization`
-/
namespace Spec.System
@@ -45,9 +43,8 @@ structure PermissionGrant where
role : Role
/-- 资源策略设置(`PINNED` 结构 + 六旋钮, ADR-0004 `PermissionSettings`):与 grant 分离,
"此资源是否开某类操作" ADR ; `OPEN`(ADR ,
) opaque `Policy` :"旋钮存在且相互独立","各旋钮
"——值域是实现/后续 ADR 的事。 -/
"此资源是否开某类操作" ADR ; `OPEN`(ADR )
opaque `Policy` :"旋钮存在且相互独立";/ ADR -/
structure PermissionSettings where
/-- 设置所属资源(`PINNED`, ADR-0004)。 -/
resource : Resource I ArtifactId
+2 -2
View File
@@ -8,8 +8,8 @@ import Spec.Prelude
invitation sessionmutation
,线
cookie token hash TTL
/recovery key CLI/SQL `OPEN`,
cookie token hash TTL
/recovery key CLI/SQL `OPEN`
-/
namespace Spec.System
+12 -15
View File
@@ -3,26 +3,23 @@ import Spec.Prelude
/-!
# ProjectGroup (ADR-0001)
ADR-0001 : project ****;,**
owner**( `AgentRun`, `Lock` / ADR-0002), session Claude
;/ Claude
projectgroup **active**likec4 "project has group","恰好一个、
"
project ;,( `AgentRun`,
`Lock` / ADR-0002) agent ;/
agent
**(`PINNED`, ADR-0021):** active binding 1:1; archived
historical binding rows /, `GroupBinding` active
/ `OPEN`;pilot org admin
**(`PINNED`, ADR-0021):** active binding 1:1; archived
historical binding rows , `GroupBinding` active
/ `OPEN`;pilot org admin
-/
namespace Spec.System
variable (I : Identifiers)
/-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的绑定;
** owner**( `AgentRun`, `Lock`); session -/
/-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的
;( `AgentRun`, `Lock`) -/
structure ProjectGroup where
/-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001 "one project has one Feishu project
group";chat 标识见 `Identifiers.ChatId`)。 -/
/-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001;chat 标识见 `Identifiers.ChatId`)。 -/
chat : I.ChatId
/-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。
@@ -30,9 +27,9 @@ structure ProjectGroup where
); -/
def GroupBinding := I.ProjectId Option I.ChatId
/-- Active 绑定良构:**单射**——不同 project 不绑同一 active chat(`PINNED` 1:1 的另一半,
ADR-0001/0021)"每 project 至多一个群" `Option` ;"每群至多属于一个
project"。archived historical bindings 不在本快照不变式内。 -/
/-- Active 绑定良构:单射——不同 project 不绑同一 active chat(`PINNED`, ADR-0001/0021)。
"每 project 至多一个群" `Option` ;"每群至多属于一个 project"
archived historical bindings -/
def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂
+5 -6
View File
@@ -3,12 +3,11 @@ import Spec.Prelude
/-!
# ProjectWorkspace project explorer (ADR-0021)
ADR-0021 org "文件管理器式" folder + project:
folder ,project
org "文件管理器式":folder ,
project
:folder/project orgfolder
project org policy folder visibility/team policy/
,
:folder/project orgfolder project
org policy folder visibility/team policy/ `OPEN`
-/
namespace Spec.System
@@ -39,7 +38,7 @@ def ProjectFolderPlacement.WellScoped
o, projectOrg placement.project = some o folderOrg placement.folder = some o
/-- Folder 当前透明(`PINNED`, ADR-0021):folder 不是权限资源,不持有 grants,移动 project
project folder policy , -/
project folder policy , -/
structure FolderTransparent where
/-- 透明性命题本身;字段存在是为了让 contract 明确可引用(`PINNED`, ADR-0021)。 -/
current : True
+12
View File
@@ -0,0 +1,12 @@
import Spec.Prelude
/-!
# User
`Hierarchy.User`; `Spec.System.Connections`
; `OPEN`
-/
namespace Spec.System
end Spec.System