diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aadee84..84e81e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added +- #559:新增协作区人工验收契约,验收通过必须完整覆盖计划中的全部验收项;限制标识符、时间戳和产物路径,拒绝遗漏或越界的裁决。 + - #469:可选 Laya 接力停止建议。仅在 `ROLEWEAVE_LAYA_ENABLED` 开启、已完成步骤得到正向建议且仍有未执行步骤时暂停;桌面端由 owner 显式选择“停止剩余步骤”或“继续接力”。30 秒超时、SSE 断连、无效或失败建议均继续原 mention order;Laya 只接收 `status`、`errorCode`、`hasOutput`,不接收输入、输出或 handoff 文本。已接受的消息与 spawn 清单保持不可变。Refs #422。 - #360:工作区 `work/` 领地布局。initialize/create 脚手架 `work/README.md`(已有工作区不加强制迁移)。hire 创建 `work//`,仅给 hire 岗位生成 SKILL.md Territory 段,并把新角色的 `memoryScope` 写成 `./work//`;项目负责人保持 `memoryScope: "/"`,SKILL 不声称不存在的 `work//`。默认 `toolAllow` 只有 Read/Grep/Glob,不含 Write/Edit/Bash。hire 任何 staging 失败都回滚声明文件。employee.json 摘要密封机制不变。Refs #360。 diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index ba1d12a4..2c4f9e82 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -160,6 +160,7 @@ const SHARED_RUNTIME_FILES = [ "pending-approval.cjs", "position-id.cjs", "turn-engines.cjs", + "dist/acceptance.js", "dist/agent-binding.js", "dist/api.js", "dist/approval-preview.js", diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts new file mode 100644 index 00000000..bcefd0bd --- /dev/null +++ b/apps/server/test/acceptance.test.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ACCEPTANCE_SCHEMA_VERSION, + validateAcceptanceCreateRequest, + validateAcceptanceRecord, + type AcceptanceRecord, +} from "@roleweave/shared"; + +function baseRecord(overrides: Record = {}): Record { + return { + schemaVersion: ACCEPTANCE_SCHEMA_VERSION, + acceptanceId: "acc-1", + spaceId: "space-billing", + source: { positionId: "analyst" }, + criteriaCount: 2, + verdicts: [ + { criteriaIndex: 0, passed: true }, + { criteriaIndex: 1, passed: true, evidencePath: "产出/增量更新.md" }, + ], + decision: "accepted", + decidedBy: "operator", + decidedAt: "2026-10-01T12:00:00.000Z", + ...overrides, + }; +} + +test("accepts a well-formed accepted record", () => { + const result = validateAcceptanceRecord(baseRecord()); + assert.equal(result.ok, true); + if (!result.ok) return; + const value: AcceptanceRecord = result.value; + assert.equal(value.schemaVersion, ACCEPTANCE_SCHEMA_VERSION); + assert.equal(value.decision, "accepted"); + assert.equal(value.verdicts.length, 2); + assert.equal(value.verdicts[1]?.evidencePath, "产出/增量更新.md"); +}); + +test("rejects an accepted decision when a criterion is not passed", () => { + const result = validateAcceptanceRecord( + baseRecord({ verdicts: [{ criteriaIndex: 0, passed: true }, { criteriaIndex: 1, passed: false }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_incomplete_verdicts"); +}); + +test("rejects an accepted decision with an empty verdict list", () => { + const result = validateAcceptanceRecord(baseRecord({ verdicts: [] })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_incomplete_verdicts"); +}); + +test("rejects a rejected decision with an empty note", () => { + const result = validateAcceptanceRecord( + baseRecord({ decision: "rejected", note: " ", verdicts: [{ criteriaIndex: 0, passed: false }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_missing_note"); +}); + +test("rejects a rejected decision with no note at all", () => { + const result = validateAcceptanceRecord( + baseRecord({ decision: "rejected", verdicts: [{ criteriaIndex: 0, passed: false }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_missing_note"); +}); + +test("keeps a rejected record that carries a reason note", () => { + const result = validateAcceptanceRecord( + baseRecord({ decision: "rejected", note: "criterion 1 has no reproducible evidence", verdicts: [{ criteriaIndex: 0, passed: false }] }), + ); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.decision, "rejected"); + assert.equal(result.value.note, "criterion 1 has no reproducible evidence"); +}); + +test("rejects duplicate criteriaIndex values", () => { + const result = validateAcceptanceRecord( + baseRecord({ verdicts: [{ criteriaIndex: 0, passed: true }, { criteriaIndex: 0, passed: true }] }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_duplicate_reference"); +}); + +test("rejects a negative or non-integer criteriaIndex", () => { + for (const criteriaIndex of [-1, 1.5]) { + const result = validateAcceptanceRecord( + baseRecord({ verdicts: [{ criteriaIndex, passed: true }] }), + ); + assert.equal(result.ok, false, `criteriaIndex ${criteriaIndex} should be rejected`); + if (result.ok) continue; + assert.equal(result.code, "acceptance_invalid"); + } +}); + +test("rejects an unsupported schemaVersion", () => { + const result = validateAcceptanceRecord(baseRecord({ schemaVersion: "acceptance.v2" })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid"); +}); + +test("rejects unknown top-level keys", () => { + const result = validateAcceptanceRecord(baseRecord({ surprise: true })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_unknown_field"); +}); + +test("rejects a decision outside the allowlist", () => { + const result = validateAcceptanceRecord(baseRecord({ decision: "maybe" })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid_enum"); +}); + +test("rejects a source with an unexpected field", () => { + const result = validateAcceptanceRecord( + baseRecord({ source: { positionId: "analyst", sneaky: "x" } }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_unknown_field"); +}); + +test("accepts a source carrying turnId and artifactPath", () => { + const result = validateAcceptanceRecord( + baseRecord({ source: { positionId: "analyst", turnId: "turn-9", artifactPath: "产出/增量更新.md" } }), + ); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.source.turnId, "turn-9"); + assert.equal(result.value.source.artifactPath, "产出/增量更新.md"); +}); + +test("rejects a non-string note", () => { + const result = validateAcceptanceRecord( + baseRecord({ + decision: "rejected", + note: 42, + criteriaCount: 1, + verdicts: [{ criteriaIndex: 0, passed: false }], + }), + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid"); +}); + +test("rejects a whitespace-bearing identifier", () => { + const result = validateAcceptanceRecord(baseRecord({ spaceId: "space billing" })); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_invalid"); +}); + +test("rejects a missing required field", () => { + const raw = baseRecord(); + delete raw.decidedBy; + const result = validateAcceptanceRecord(raw); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.code, "acceptance_unknown_field"); +}); + +test("create request mirrors the same decision/verdict consistency", () => { + const rejected = validateAcceptanceCreateRequest({ + spaceId: "space-billing", + source: { positionId: "analyst" }, + criteriaCount: 1, + verdicts: [{ criteriaIndex: 0, passed: false }], + decision: "accepted", + }); + assert.equal(rejected.ok, false); + if (rejected.ok) return; + assert.equal(rejected.code, "acceptance_incomplete_verdicts"); + + const accepted = validateAcceptanceCreateRequest({ + spaceId: "space-billing", + source: { positionId: "analyst" }, + criteriaCount: 1, + verdicts: [{ criteriaIndex: 0, passed: true }], + decision: "accepted", + }); + assert.equal(accepted.ok, true); +}); + + +test("accepted records cover exactly the declared criterion set", () => { + for (const verdicts of [[{criteriaIndex: 5, passed: true}], [{criteriaIndex: 0, passed: true}], [{criteriaIndex: 0, passed: true}, {criteriaIndex: 2, passed: true}]]) { + const result = validateAcceptanceRecord(baseRecord({verdicts})); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.code, "acceptance_incomplete_verdicts"); + } + assert.equal(validateAcceptanceRecord(baseRecord({verdicts: [{criteriaIndex: 1, passed: true}, {criteriaIndex: 0, passed: true}]})).ok, true); +}); + +test("criteria count is required and bounded, and empty plans cannot be accepted", () => { + const raw = baseRecord(); delete raw.criteriaCount; + assert.equal(validateAcceptanceRecord(raw).ok, false); + for (const criteriaCount of [-1, 1.5, 65, "2", 0]) assert.equal(validateAcceptanceRecord(baseRecord({criteriaCount})).ok, false); + const verdicts = Array.from({length: 64}, (_, criteriaIndex) => ({criteriaIndex, passed: true})); + assert.equal(validateAcceptanceRecord(baseRecord({criteriaCount: 64, verdicts})).ok, true); + assert.equal(validateAcceptanceRecord(baseRecord({criteriaCount: 64, verdicts: [...verdicts, {criteriaIndex: 64, passed: true}]})).ok, false); + assert.equal(validateAcceptanceRecord(baseRecord({criteriaCount: 0, verdicts: [], decision: "rejected", note: "missing plan"})).ok, true); +}); + +test("all identifier fields enforce the exported 128-character bound", () => { + for (const field of ["acceptanceId", "spaceId", "decidedBy"]) { + assert.equal(validateAcceptanceRecord(baseRecord({[field]: "a".repeat(128)})).ok, true); + assert.equal(validateAcceptanceRecord(baseRecord({[field]: "a".repeat(129)})).ok, false); + } + for (const field of ["positionId", "turnId"]) { + assert.equal(validateAcceptanceRecord(baseRecord({source: {positionId: "p", [field]: "a".repeat(128)}})).ok, true); + assert.equal(validateAcceptanceRecord(baseRecord({source: {positionId: "p", [field]: "a".repeat(129)}})).ok, false); + } +}); + +test("decidedAt requires a real UTC instant rather than Date.parse shorthand", () => { + for (const decidedAt of ["1", "2026", "2026-10-01", "Fri Oct 02 2026", "2026-02-30T00:00:00Z", "2026-13-01T00:00:00Z", "2026-10-01T24:00:00Z"]) + assert.equal(validateAcceptanceRecord(baseRecord({decidedAt})).ok, false, decidedAt); + for (const decidedAt of ["2026-10-01T12:00:00Z", "2026-10-01T12:00:00.1Z", "2026-10-01T12:00:00.123Z"]) + assert.equal(validateAcceptanceRecord(baseRecord({decidedAt})).ok, true, decidedAt); +}); + +test("unsafe controls and bidi characters cannot enter ids, notes or paths", () => { + for (const control of ["\u0001", "\u007f", "\u0085", "\u009f", "\u200b", "\u202e", "\u2066"]) { + for (const override of [{spaceId: `a${control}b`}, {note: `a${control}b`}, {source: {positionId: "p", artifactPath: `a${control}b`}}]) + assert.equal(validateAcceptanceRecord(baseRecord(override)).ok, false); + } + assert.equal(validateAcceptanceRecord(baseRecord({note: "line one\nline two\tend"})).ok, true); +}); + +test("artifact pointers are relative and bounded and notes respect their limit", () => { + for (const artifactPath of ["../secret", "/absolute", "a/../b", "C:/absolute", "a\\b", "a//b", "a".repeat(1025)]) + assert.equal(validateAcceptanceRecord(baseRecord({source: {positionId: "p", artifactPath}})).ok, false, artifactPath); + assert.equal(validateAcceptanceRecord(baseRecord({source: {positionId: "p", artifactPath: "a".repeat(1024)}})).ok, true); + assert.equal(validateAcceptanceRecord(baseRecord({note: "a".repeat(4096)})).ok, true); + assert.equal(validateAcceptanceRecord(baseRecord({note: "a".repeat(4097)})).ok, false); + assert.equal(validateAcceptanceRecord(baseRecord({note: " ".repeat(4097)})).ok, false); +}); + +test("create requests also refuse omitted, partial and out-of-range criterion coverage", () => { + const {schemaVersion, acceptanceId, decidedBy, decidedAt, ...request} = baseRecord(); + assert.equal(validateAcceptanceCreateRequest(request).ok, true); + for (const overrides of [{criteriaCount: undefined}, {verdicts: [{criteriaIndex: 0, passed: true}]}, {verdicts: [{criteriaIndex: 2, passed: true}]}]) + assert.equal(validateAcceptanceCreateRequest({...request, ...overrides}).ok, false); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index c62beb22..ec21dec8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -45,6 +45,10 @@ "types": "./dist/goals.d.ts", "import": "./dist/goals.js" }, + "./acceptance": { + "types": "./dist/acceptance.d.ts", + "import": "./dist/acceptance.js" + }, "./task-board": { "types": "./dist/task-board.d.ts", "import": "./dist/task-board.js" diff --git a/packages/shared/src/acceptance.ts b/packages/shared/src/acceptance.ts new file mode 100644 index 00000000..aeb6e9fa --- /dev/null +++ b/packages/shared/src/acceptance.ts @@ -0,0 +1,390 @@ +/** + * Acceptance contracts (v1). + * + * An AcceptanceRecord is the human act of accepting a deliverable produced by + * an agent inside a space. It is deliberately separate from an approval: + * + * - approval -> permission to act, owned by the workspace/organization, + * modelled by ApprovalRecord (packages/shared/src/approvals.ts) + * - acceptance -> a verdict on work already done, owned by the space + * + * The distinction matters because an agent may only ever push work to `review`. + * Only an acceptance decision may move it to `done`; the plan side + * (GoalWorkItem) and the execution side (GoalTaskExecution) never overwrite + * each other. + * + * Validators are fail-closed: object keys are allowlisted, enums are closed, + * text is bounded, and the decision is cross-checked against the verdicts. + */ + +export const ACCEPTANCE_SCHEMA_VERSION = "acceptance.v1" as const; + +export const ACCEPTANCE_MAX_ID_LENGTH = 128; +export const ACCEPTANCE_MAX_SHORT_TEXT_LENGTH = 256; +export const ACCEPTANCE_MAX_TEXT_LENGTH = 4_096; +export const ACCEPTANCE_MAX_PATH_LENGTH = 1_024; +export const ACCEPTANCE_MAX_VERDICTS = 64; + +export const acceptanceDecisions = ["accepted", "rejected"] as const; +export type AcceptanceDecision = (typeof acceptanceDecisions)[number]; + +/** Per-criterion verdict. An accepted record must cover every criterion. */ +export interface AcceptanceVerdict { + criteriaIndex: number; + passed: boolean; + /** Space-relative artifact path; consumers must also enforce filesystem confinement. */ + evidencePath?: string; +} + +export interface AcceptanceSource { + positionId: string; + turnId?: string; + /** Space-relative path; never an authorization to read the file. */ + artifactPath?: string; +} + +export interface AcceptanceRecord { + schemaVersion: typeof ACCEPTANCE_SCHEMA_VERSION; + acceptanceId: string; + spaceId: string; + source: AcceptanceSource; + /** Number of criteria in the plan being accepted (0–64). */ + criteriaCount: number; + verdicts: AcceptanceVerdict[]; + decision: AcceptanceDecision; + note?: string; + decidedBy: string; + decidedAt: string; +} + +export interface AcceptanceCreateRequest { + spaceId: string; + source: AcceptanceSource; + /** Number of criteria in the plan being accepted (0–64). */ + criteriaCount: number; + verdicts: AcceptanceVerdict[]; + decision: AcceptanceDecision; + note?: string; +} + +// ── Validators ────────────────────────────────────────────────────────────── + +export type AcceptanceValidationCode = + | "acceptance_invalid" + | "acceptance_unknown_field" + | "acceptance_empty_string" + | "acceptance_text_too_long" + | "acceptance_invalid_enum" + | "acceptance_duplicate_reference" + | "acceptance_incomplete_verdicts" + | "acceptance_missing_note"; + +export interface AcceptanceValidationFailure { + ok: false; + code: AcceptanceValidationCode; + message: string; +} + +export interface AcceptanceValidationSuccess { + ok: true; + value: T; +} + +export type AcceptanceValidationResult = AcceptanceValidationSuccess | AcceptanceValidationFailure; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function fail(code: AcceptanceValidationCode, message: string): AcceptanceValidationFailure { + return { ok: false, code, message }; +} + +function keysMatch( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = new Set([...required, ...optional]); + return ( + Object.keys(value).every((key) => allowed.has(key)) && + required.every((key) => Object.hasOwn(value, key)) + ); +} + +function hasUnsafeControl(value: string): boolean { + return /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/u.test(value); +} + +function nonEmptyText( + value: unknown, + field: string, + maxLength = ACCEPTANCE_MAX_TEXT_LENGTH, +): AcceptanceValidationResult { + if (typeof value !== "string" || value.trim().length === 0) { + return fail("acceptance_empty_string", `${field} must be a non-empty string`); + } + if (value.length > maxLength) { + return fail("acceptance_text_too_long", `${field} exceeds its text bound`); + } + if (hasUnsafeControl(value)) { + return fail("acceptance_invalid", `${field} contains a control character`); + } + return { ok: true, value }; +} + +function identifier(value: unknown, field: string): AcceptanceValidationResult { + const result = nonEmptyText(value, field, ACCEPTANCE_MAX_ID_LENGTH); + if (!result.ok) return result; + if (/\s/.test(result.value)) { + return fail("acceptance_invalid", `${field} must not contain whitespace`); + } + return result; +} + +function pathText(value: unknown, field: string): AcceptanceValidationResult { + const result = nonEmptyText(value, field, ACCEPTANCE_MAX_PATH_LENGTH); + if (!result.ok) return result; + if (/^[\/\\]|^[A-Za-z]:|[\r\n\t\\]/u.test(result.value) || result.value.split("/").some((part) => part === ".." || part === "." || part === "")) { + return fail("acceptance_invalid", `${field} must be a space-relative file path`); + } + return result; +} + +function enumValue( + value: unknown, + values: readonly T[], + field: string, +): AcceptanceValidationResult { + if (typeof value !== "string" || !values.includes(value as T)) { + return fail("acceptance_invalid_enum", `${field} is outside the allowlist`); + } + return { ok: true, value: value as T }; +} + +function iso8601(value: unknown, field: string): AcceptanceValidationResult { + if (typeof value !== "string") return fail("acceptance_invalid", `${field} must be a string`); + if (value.length > ACCEPTANCE_MAX_SHORT_TEXT_LENGTH) { + return fail("acceptance_text_too_long", `${field} exceeds its text bound`); + } + const instant = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value); + const timestamp = Date.parse(value); + const canonical = value.replace(/(?:\.(\d{1,3}))?Z$/, (_match, fraction: string | undefined) => `.${(fraction ?? "").padEnd(3, "0")}Z`); + if (!instant || Number.isNaN(timestamp) || new Date(timestamp).toISOString() !== canonical) { + return fail("acceptance_invalid", `${field} is not a valid ISO-8601 timestamp`); + } + return { ok: true, value }; +} + +function validateSource(raw: unknown, field: string): AcceptanceValidationResult { + if (!isRecord(raw) || !keysMatch(raw, ["positionId"], ["turnId", "artifactPath"])) { + return fail("acceptance_unknown_field", `${field} has unexpected or missing fields`); + } + const positionId = identifier(raw.positionId, `${field}.positionId`); + if (positionId.ok === false) return fail(positionId.code, positionId.message); + + const source: AcceptanceSource = { positionId: positionId.value }; + + if (raw.turnId !== undefined) { + const turnId = identifier(raw.turnId, `${field}.turnId`); + if (turnId.ok === false) return fail(turnId.code, turnId.message); + source.turnId = turnId.value; + } + if (raw.artifactPath !== undefined) { + const artifactPath = pathText(raw.artifactPath, `${field}.artifactPath`); + if (artifactPath.ok === false) return fail(artifactPath.code, artifactPath.message); + source.artifactPath = artifactPath.value; + } + return { ok: true, value: source }; +} + +function validateCriteriaCount(raw: unknown): AcceptanceValidationResult { + if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw > ACCEPTANCE_MAX_VERDICTS) { + return fail("acceptance_invalid", `criteriaCount must be an integer from 0 to ${ACCEPTANCE_MAX_VERDICTS}`); + } + return { ok: true, value: raw }; +} + +function validateVerdicts(raw: unknown): AcceptanceValidationResult { + if (!Array.isArray(raw)) return fail("acceptance_invalid", "verdicts must be an array"); + if (raw.length > ACCEPTANCE_MAX_VERDICTS) { + return fail("acceptance_invalid", `verdicts has too many items (max ${ACCEPTANCE_MAX_VERDICTS})`); + } + const seen = new Set(); + const verdicts: AcceptanceVerdict[] = []; + for (let index = 0; index < raw.length; index += 1) { + const item = raw[index]; + const field = `verdicts[${index}]`; + if (!isRecord(item) || !keysMatch(item, ["criteriaIndex", "passed"], ["evidencePath"])) { + return fail("acceptance_unknown_field", `${field} has unexpected or missing fields`); + } + if ( + typeof item.criteriaIndex !== "number" || + !Number.isInteger(item.criteriaIndex) || + item.criteriaIndex < 0 + ) { + return fail("acceptance_invalid", `${field}.criteriaIndex must be a non-negative integer`); + } + if (seen.has(item.criteriaIndex)) { + return fail("acceptance_duplicate_reference", `${field}.criteriaIndex is a duplicate`); + } + seen.add(item.criteriaIndex); + if (typeof item.passed !== "boolean") { + return fail("acceptance_invalid", `${field}.passed must be a boolean`); + } + const verdict: AcceptanceVerdict = { criteriaIndex: item.criteriaIndex, passed: item.passed }; + if (item.evidencePath !== undefined) { + const evidencePath = pathText(item.evidencePath, `${field}.evidencePath`); + if (!evidencePath.ok) return evidencePath; + verdict.evidencePath = evidencePath.value; + } + verdicts.push(verdict); + } + return { ok: true, value: verdicts }; +} + +/** + * Enforce the decision/verdicts cross-checks that keep an accepted record + * meaningful: + * - accepted covers exactly indices 0..criteriaCount-1, all `passed: true` + * - rejected requires a non-empty reason note + * + * `note` is taken raw so a blank note on a rejection is reported as the + * specific `acceptance_missing_note` rather than a generic empty string. + */ +function checkDecisionConsistency( + decision: AcceptanceDecision, + criteriaCount: number, + verdicts: AcceptanceVerdict[], + note: string | undefined, +): { ok: true } | { ok: false; code: AcceptanceValidationCode; message: string } { + if (verdicts.some((verdict) => verdict.criteriaIndex >= criteriaCount)) { + return fail("acceptance_incomplete_verdicts", "verdict index is outside the declared criteria count"); + } + if (decision === "accepted") { + if (criteriaCount === 0 || verdicts.length !== criteriaCount || verdicts.some((verdict) => !verdict.passed)) { + return fail( + "acceptance_incomplete_verdicts", + "an accepted record requires every criterion to be present and passed", + ); + } + return { ok: true }; + } + // rejected + if (note === undefined || note.trim().length === 0) { + return fail("acceptance_missing_note", "a rejected record requires a non-empty note"); + } + return { ok: true }; +} + +/** + * A rejection note is read raw so the decision/verdict consistency check owns + * the "blank reason" verdict; a present-but-unparsable note still fails loudly. + */ +function parseNote( + raw: unknown, + fallbackField: string, +): AcceptanceValidationResult { + if (raw === undefined) return { ok: true, value: undefined }; + if (typeof raw !== "string") return fail("acceptance_invalid", `${fallbackField} must be a string`); + if (raw.length > ACCEPTANCE_MAX_TEXT_LENGTH) { + return fail("acceptance_text_too_long", `${fallbackField} exceeds its text bound`); + } + if (hasUnsafeControl(raw)) { + return fail("acceptance_invalid", `${fallbackField} contains a control character`); + } + return { ok: true, value: raw }; +} + +export function validateAcceptanceRecord(raw: unknown): AcceptanceValidationResult { + if ( + !isRecord(raw) || + !keysMatch( + raw, + ["schemaVersion", "acceptanceId", "spaceId", "source", "criteriaCount", "verdicts", "decision", "decidedBy", "decidedAt"], + ["note"], + ) + ) { + return fail("acceptance_unknown_field", "acceptance has unexpected or missing fields"); + } + if (raw.schemaVersion !== ACCEPTANCE_SCHEMA_VERSION) { + return fail("acceptance_invalid", "acceptance.schemaVersion is not supported"); + } + const acceptanceId = identifier(raw.acceptanceId, "acceptance.acceptanceId"); + if (!acceptanceId.ok) return acceptanceId; + const spaceId = identifier(raw.spaceId, "acceptance.spaceId"); + if (!spaceId.ok) return spaceId; + const source = validateSource(raw.source, "acceptance.source"); + if (!source.ok) return source; + const criteriaCount = validateCriteriaCount(raw.criteriaCount); + if (!criteriaCount.ok) return criteriaCount; + const verdicts = validateVerdicts(raw.verdicts); + if (!verdicts.ok) return verdicts; + const decision = enumValue(raw.decision, acceptanceDecisions, "acceptance.decision"); + if (!decision.ok) return decision; + const decidedBy = identifier(raw.decidedBy, "acceptance.decidedBy"); + if (!decidedBy.ok) return decidedBy; + const decidedAt = iso8601(raw.decidedAt, "acceptance.decidedAt"); + if (!decidedAt.ok) return decidedAt; + + const parsedNote = parseNote(raw.note, "acceptance.note"); + if (!parsedNote.ok) return parsedNote; + const note = parsedNote.value; + + const consistency = checkDecisionConsistency(decision.value, criteriaCount.value, verdicts.value, note); + if (!consistency.ok) return fail(consistency.code, consistency.message); + + return { + ok: true, + value: { + schemaVersion: ACCEPTANCE_SCHEMA_VERSION, + acceptanceId: acceptanceId.value, + spaceId: spaceId.value, + source: source.value, + criteriaCount: criteriaCount.value, + verdicts: verdicts.value, + decision: decision.value, + ...(note !== undefined ? { note } : {}), + decidedBy: decidedBy.value, + decidedAt: decidedAt.value, + }, + }; +} + +export function validateAcceptanceCreateRequest(raw: unknown): AcceptanceValidationResult { + if ( + !isRecord(raw) || + !keysMatch(raw, ["spaceId", "source", "criteriaCount", "verdicts", "decision"], ["note"]) + ) { + return fail("acceptance_unknown_field", "create request has unexpected or missing fields"); + } + const spaceId = identifier(raw.spaceId, "spaceId"); + if (!spaceId.ok) return spaceId; + const source = validateSource(raw.source, "source"); + if (!source.ok) return source; + const criteriaCount = validateCriteriaCount(raw.criteriaCount); + if (!criteriaCount.ok) return criteriaCount; + const verdicts = validateVerdicts(raw.verdicts); + if (!verdicts.ok) return verdicts; + const decision = enumValue(raw.decision, acceptanceDecisions, "decision"); + if (!decision.ok) return decision; + + const parsedNote = parseNote(raw.note, "note"); + if (!parsedNote.ok) return parsedNote; + const note = parsedNote.value; + + const consistency = checkDecisionConsistency(decision.value, criteriaCount.value, verdicts.value, note); + if (!consistency.ok) return fail(consistency.code, consistency.message); + + return { + ok: true, + value: { + spaceId: spaceId.value, + source: source.value, + criteriaCount: criteriaCount.value, + verdicts: verdicts.value, + decision: decision.value, + ...(note !== undefined ? { note } : {}), + }, + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c0c4f19e..64134633 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -26,6 +26,7 @@ export * from "./drive.js"; export * from "./context-sources.js"; export * from "./updates.js"; export * from "./goals.js"; +export * from "./acceptance.js"; export * from "./task-board.js"; export * from "./relationship-graph.js"; export * from "./avatar.js";