From 6f01e749796efc8f512e81900f39d8aa250c62f5 Mon Sep 17 00:00:00 2001
From: huyz <739820174@qq.com>
Date: Sat, 26 Sep 2026 00:24:49 +0800
Subject: [PATCH 1/4] fix(services): keep unsaved connection drafts across
background refreshes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ConnectionForm sync effect unconditionally reset every field,
including a typed PAT, whenever the saved connection values changed.
A background services refresh landing while a draft was unsaved could
therefore silently discard the user's input, and the next submit went
out without a token.
Guard the sync effect with a dirty flag: user edits mark the form dirty
and win over background refreshes; the flag clears once a save or
disconnect accepts the server state. Add a regression test where a
refresh lands with changed saved addresses while a PAT is typed — it
fails on main and passes with this change.
Refs #512
---
.../src/settings/ServiceConnections.tsx | 14 ++++++----
.../test/service-connections.test.tsx | 26 +++++++++++++++++++
2 files changed, 35 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/renderer/src/settings/ServiceConnections.tsx b/apps/desktop/renderer/src/settings/ServiceConnections.tsx
index dee09bdb..d385debe 100644
--- a/apps/desktop/renderer/src/settings/ServiceConnections.tsx
+++ b/apps/desktop/renderer/src/settings/ServiceConnections.tsx
@@ -34,12 +34,14 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
const tokenRef = useRef(token);
tokenRef.current = token;
const busyRef = useRef(false);
+ const dirtyRef = useRef(false);
const savedApiUrl = connection.apiUrl ?? "";
const savedWebUrl = connection.webUrl ?? "";
const savedWorkspaceId = connection.workspaceId ?? "";
const savedTokenConfigured = connection.tokenConfigured;
useEffect(() => {
+ if (dirtyRef.current) return; // an unsaved draft wins over a background refresh
setApiUrl(savedApiUrl);
setWebUrl(savedWebUrl);
setWorkspaceId(savedWorkspaceId);
@@ -64,6 +66,7 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
});
} catch { setNotice({ type: "error", key: "services.failed" }); return; }
if (response.status !== 200) { setNotice({ type: "error", key: serviceErrorKey(response.body) }); return; }
+ dirtyRef.current = false;
setToken(""); setProbe(null); onChange(response.body);
setNotice({ type: "success", key: "services.saved" });
window.dispatchEvent(new Event(SERVICES_CHANGED));
@@ -81,6 +84,7 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
async function disconnect() {
const response = await window.owb.services.disconnect(kind);
if (response.status !== 200) { setNotice({ type: "error", key: serviceErrorKey(response.body) }); return; }
+ dirtyRef.current = false;
setProbe(null); onChange(response.body);
setNotice({ type: "success", key: "services.disconnected" });
window.dispatchEvent(new Event(SERVICES_CHANGED));
@@ -104,27 +108,27 @@ function ConnectionForm({ connection, onChange, operationsOnly = false }: { conn
{t("services.apiUrl")}
setApiUrl(event.target.value)} autoComplete="off" spellCheck={false} />
+ onChange={(event) => { dirtyRef.current = true; setApiUrl(event.target.value); }} autoComplete="off" spellCheck={false} />
{kind === "mem" ? : null}
{connection.tokenConfigured ? { setClearToken(event.target.checked); setToken(""); }}>{t("services.clearToken")} : null}
+ onChange={(event) => { dirtyRef.current = true; setClearToken(event.target.checked); setToken(""); }}>{t("services.clearToken")} : null}
{t("services.addressHint")}
> : null}
diff --git a/apps/desktop/renderer/test/service-connections.test.tsx b/apps/desktop/renderer/test/service-connections.test.tsx
index 79995851..0ea7e0ea 100644
--- a/apps/desktop/renderer/test/service-connections.test.tsx
+++ b/apps/desktop/renderer/test/service-connections.test.tsx
@@ -106,4 +106,30 @@ describe("independent service connections", () => {
await waitFor(() => expect(services.list.mock.calls.length).toBeGreaterThan(2));
expect(screen.getAllByRole("button", { name: "打开 Doc" }).length).toBe(2);
});
+
+ it("keeps a typed PAT when a background refresh lands with changed saved addresses", async () => {
+ const services = installBridge();
+ render(
);
+ const token = await screen.findByLabelText("个人访问令牌(PAT)");
+ fireEvent.change(token, { target: { value: "fresh-pat" } });
+ services.list.mockImplementation(async () => ({
+ status: 200,
+ body: {
+ connections: [{
+ kind: "doc",
+ apiUrl: "https://docs.example/v2",
+ webUrl: "https://docs.example/work2",
+ configured: true,
+ tokenConfigured: true,
+ workspaceId: null,
+ }],
+ },
+ }));
+ window.dispatchEvent(new Event("owb:services-changed"));
+ await waitFor(() => expect(services.list.mock.calls.length).toBeGreaterThan(1));
+ expect(token).toHaveValue("fresh-pat");
+ fireEvent.click(screen.getByRole("button", { name: "保存连接" }));
+ await screen.findByText("连接已保存,可以检查服务是否可访问。");
+ expect(services.configure).toHaveBeenLastCalledWith(expect.objectContaining({ kind: "doc", token: "fresh-pat" }));
+ });
});
From b798429e5717c99edb383d3fe352da95a1736bce Mon Sep 17 00:00:00 2001
From: PeterGuy326
Date: Thu, 1 Oct 2026 23:07:20 +0800
Subject: [PATCH 2/4] feat(space): add acceptance.v1 contract for human
acceptance verdicts
Introduces the acceptance record that lets a human accept or reject an
agent's deliverable inside a space. This is deliberately separate from
approval: an approval gates permission to act, an acceptance is a verdict
on work already done. Agents may only push work to review; only an
acceptance may move it to done.
Validators are fail-closed in the style of goal.v1: allowlisted keys,
closed enums, bounded text, and decision/verdict cross-checks:
- accepted requires a verdict for every criterion, all passed
- rejected requires a non-empty reason note
- criteriaIndex values must be unique and non-negative
Contract + tests only; the space/document UI lands in a follow-up once
this shape is agreed.
Refs #558
---
apps/server/test/acceptance.test.ts | 189 +++++++++++++++
packages/shared/package.json | 4 +
packages/shared/src/acceptance.ts | 357 ++++++++++++++++++++++++++++
packages/shared/src/index.ts | 1 +
4 files changed, 551 insertions(+)
create mode 100644 apps/server/test/acceptance.test.ts
create mode 100644 packages/shared/src/acceptance.ts
diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts
new file mode 100644
index 00000000..7c50d16c
--- /dev/null
+++ b/apps/server/test/acceptance.test.ts
@@ -0,0 +1,189 @@
+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" },
+ 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,
+ 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" },
+ 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" },
+ verdicts: [{ criteriaIndex: 0, passed: true }],
+ decision: "accepted",
+ });
+ assert.equal(accepted.ok, true);
+});
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..34cd32c1
--- /dev/null
+++ b/packages/shared/src/acceptance.ts
@@ -0,0 +1,357 @@
+/**
+ * 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;
+ /** Optional pointer to the artifact that satisfies the criterion. */
+ evidencePath?: string;
+}
+
+export interface AcceptanceSource {
+ positionId: string;
+ turnId?: string;
+ artifactPath?: string;
+}
+
+export interface AcceptanceRecord {
+ schemaVersion: typeof ACCEPTANCE_SCHEMA_VERSION;
+ acceptanceId: string;
+ spaceId: string;
+ source: AcceptanceSource;
+ verdicts: AcceptanceVerdict[];
+ decision: AcceptanceDecision;
+ note?: string;
+ decidedBy: string;
+ decidedAt: string;
+}
+
+export interface AcceptanceCreateRequest {
+ spaceId: string;
+ source: AcceptanceSource;
+ 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 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 ([...value].some((ch) => ch.charCodeAt(0) < 0x20 && ch !== "\n" && ch !== "\r" && ch !== "\t")) {
+ 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_SHORT_TEXT_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 {
+ return nonEmptyText(value, field, ACCEPTANCE_MAX_PATH_LENGTH);
+}
+
+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`);
+ }
+ if (Number.isNaN(Date.parse(value))) {
+ 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 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 requires a non-empty verdict list, 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,
+ verdicts: AcceptanceVerdict[],
+ note: string | undefined,
+): { ok: true } | { ok: false; code: AcceptanceValidationCode; message: string } {
+ if (decision === "accepted") {
+ if (verdicts.length === 0 || 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.trim().length === 0) return { ok: true, value: raw };
+ if (raw.length > ACCEPTANCE_MAX_TEXT_LENGTH) {
+ return fail("acceptance_text_too_long", `${fallbackField} exceeds its text bound`);
+ }
+ if ([...raw].some((ch) => ch.charCodeAt(0) < 0x20 && ch !== "\n" && ch !== "\r" && ch !== "\t")) {
+ 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", "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 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, 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,
+ 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", "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 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, verdicts.value, note);
+ if (!consistency.ok) return fail(consistency.code, consistency.message);
+
+ return {
+ ok: true,
+ value: {
+ spaceId: spaceId.value,
+ source: source.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";
From b0fa6209112a3c89f7fc9a4937bdc3871791170c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 1 Oct 2026 23:47:06 +0800
Subject: [PATCH 3/4] fix(space): narrow verdict index access for
noUncheckedIndexedAccess
Same class of error as the space-doc suite: the base tsconfig sets
noUncheckedIndexedAccess and apps/server/tsconfig.json includes test/**,
so `verdicts[1].evidencePath` is a possibly-undefined read. CI caught it
on #560; this branch carries the same line.
Refs #558
---
apps/server/test/acceptance.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/test/acceptance.test.ts b/apps/server/test/acceptance.test.ts
index 7c50d16c..bca47d7f 100644
--- a/apps/server/test/acceptance.test.ts
+++ b/apps/server/test/acceptance.test.ts
@@ -32,7 +32,7 @@ test("accepts a well-formed accepted record", () => {
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");
+ assert.equal(value.verdicts[1]?.evidencePath, "产出/增量更新.md");
});
test("rejects an accepted decision when a criterion is not passed", () => {
From 1a19cd5e45d49c78d90301b39b8cc9b1b3b52f7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 1 Oct 2026 23:52:39 +0800
Subject: [PATCH 4/4] fix(packaging): ship acceptance.js in the runtime
allowlist
SHARED_RUNTIME_FILES must match packages/shared/src in order, and
RUNTIME_FILE_SETS is what actually gets packaged. Without this entry the
acceptance contract would be missing from the built app.
Refs #558
---
apps/desktop/packaging/runtime-layout.cjs | 1 +
1 file changed, 1 insertion(+)
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",