Repository navigation
feat(space): acceptance.v1 contract for human acceptance verdicts - #559
Conversation
…shes 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
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
CI failed at `npm run check` with 19 TS errors, all in the new server
tests. The cause is mundane: `apps/server/tsconfig.json` includes
`test/**/*.ts` and the base config sets `noUncheckedIndexedAccess`, so
every `list[0]` in an assertion is `T | undefined`. I had type-checked
`packages/shared/src` but never the test files, so this only surfaced in
CI.
- space-doc.test.ts: add a single `at(list, index)` helper that asserts
bounds and narrows, then route the 18 indexed assertions through it
instead of scattering `!`. The one error that was not an index read was
`{ ...doc.criteria[0], done: true }`, where spreading a possibly
undefined value widened every field to optional; `at()` fixes that too.
- acceptance.test.ts: `value.verdicts[1].evidencePath` -> optional chain.
This one is on the #559 branch as well.
Verified with the CI command shape: `tsc -p tsconfig.json` for the server
project (includes tests) is clean, and the two suites run from
`apps/server/dist` pass 34/34.
Refs #558
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
`npm run check` runs test:desktop-main, which runs build:renderer. That build failed: packages/shared/dist/position-id.js (1:9): "createRequire" is not exported by "__vite-browser-external" position-id.js does `createRequire(import.meta.url)` to load position-id.cjs, which is fine on the server and unresolvable in a browser bundle. It becomes reachable when the renderer imports *values* from the @roleweave/shared barrel, because the barrel is `export *` over every module including position-id. The control that pinned this down: #559 adds acceptance.js to shared and touches no renderer file, and its verify passes. The failure only appears once the renderer starts pulling values out of the barrel. So the two value imports move to the subpaths that package.json already exposes for them: parseSpaceDoc, renderSpaceDoc from "@roleweave/shared/space-doc" ACCEPTANCE_SCHEMA_VERSION from "@roleweave/shared/acceptance" Types stay on the barrel — `import type` is erased and cannot drag a module into the bundle. Both files keep a comment so the next value import does not reintroduce this. Verified locally: `vite build --config apps/desktop/vite.config.ts` now completes (it failed the same way before this change, even with main's App.tsx), `tsc --noEmit -p apps/desktop` is clean, and the four renderer suites pass 24/24. Refs #558
PeterGuy326
left a comment
There was a problem hiding this comment.
Review: acceptance.v1 contract (head 1a19cd5)
Substantive review, COMMENT only (no approve/merge — author is the repo owner). CI is green. I reproduced every claim below by executing the real acceptance.ts (Node 22 --experimental-strip-types), running the 17 cases, and running a coarse mutation pass. Findings are ordered by severity; none block packaging, but the first two are domain-correctness issues worth fixing before the follow-up UI lands on top.
Constraint checks
- runtime-layout.cjs whitelist — PASS (verified, not eyeballed).
scripts/test/package-layout.test.mjsassertsSHARED_RUNTIME_FILES.filter(startsWith("dist/"))deep-equals the sortedwalkFiles(packages/shared/src)/*.ts. I replayed that assertion with the PR applied: the 38dist/entries match the 38 source modules, anddist/acceptance.jsis inserted at index 0 of thedist/block (beforedist/agent-binding.js), which is the correct alphabetical slot.git ls-treeon the head sha confirms 38.tsfiles. Thepackage.json./acceptancesubpath is inserted after./goals; that file is not order-tested and is not alphabetical by house style, sogoals -> acceptance -> task-boardis fine. - #146 CJK gate — no violation, one note. The gate (
apps/desktop/renderer/test/i18n-cjk-gate.test.ts) scans onlyrenderer/srcandpackages/ui/src, sopackages/shared/src/acceptance.tsis out of scope — and it contains zero CJK codepoints anyway (verified). The test file, however, uses CJK string literals as fixtures ("产出/增量更新.md"inapps/server/test/acceptance.test.ts:36,53,152,157). Not a gate failure, but it is the only place in the new surface carrying CJK literals; consider ASCII fixture paths ("artifacts/delta-update.md") to avoid seeding a pattern. - fail-closed — mostly PASS. Allowlisted keys at every level (top,
source, each verdict), closed enum fordecision, bounded text/lengths,Object.hasOwnfor required keys. Unknown nested keys are rejected (source.sneaky, a verdictextrakey both returnacceptance_unknown_field) — good. Gaps are listed as issues below. - Error-code semantics — PASS on the headline cases.
acceptance_incomplete_verdicts(accepted + empty/any-false verdicts),acceptance_missing_note(rejected + absent/blank/whitespace note),acceptance_duplicate_reference(repeatcriteriaIndex), andacceptance_invalid_enum(decision off-allowlist) each fire on exactly one condition; the earlier-round conflict did not survive. Remaining nits below. - TypeScript narrowing — PASS. The module uses named
interfaces (AcceptanceVerdict,AcceptanceSource,AcceptanceValidationFailure,AcceptanceValidationSuccess<T>) and a namedAcceptanceValidationResult<T>union, not anonymous object-literal unions. It compiles clean under the repo's exact flags (--strict --noUncheckedIndexedAccess --target es2022 --module nodenext) with zero errors.
Issues
1. Must-fix — "accepted requires every criterion" is not actually enforced.
checkDecisionConsistency (packages/shared/src/acceptance.ts:236-243) only checks verdicts.length !== 0 && every(passed). There is no criteria list or criteria count on AcceptanceRecord/AcceptanceCreateRequest, so the stated invariant ("An accepted record must cover every criterion", line 31, and issue #558's acceptance box) is unverifiable. Reproduced:
accepted + verdicts [{criteriaIndex:5, passed:true}] -> OK (gaps 0-4 unrecorded)
accepted + verdicts [{0,true},{99,true}] -> OK (99 not contiguous)
accepted + verdicts [{0,true},{2,true},{4,true}] -> OK (skips 1,3)
The sibling goals.ts carries acceptanceCriteria: string[] and validates it (validateCriteria, goals.ts:274); acceptance dropped the field, so a human "accept" can record a single passed criterion while criteria 0–4 were never judged. Since this PR exists to close the "human is the blocker" gate, an accepted record that silently under-covers is the one failure mode the contract was supposed to prevent.
Suggestion: either add a bounded criteriaCount: number (or a criteria id list) and assert verdicts.length === criteriaCount plus new Set(verdicts.map(v => v.criteriaIndex)).size === criteriaCount with indices proven to be 0..criteriaCount-1, or, if the criteria set is intentionally external, require criteriaIndex values to be exactly 0..n-1 in a contiguous set and document that the count comes from the plan side.
2. Should-fix — ACCEPTANCE_MAX_ID_LENGTH = 128 is exported but dead; identifiers actually allow 256.
packages/shared/src/acceptance.ts:22 declares ACCEPTANCE_MAX_ID_LENGTH = 128, but identifier() (line 128) always calls nonEmptyText(value, field, ACCEPTANCE_MAX_SHORT_TEXT_LENGTH) (256). The constant is referenced nowhere in the module (grep confirms a single occurrence, the declaration). Reproduced:
spaceId 200 chars -> OK (over the documented 128 bound)
spaceId 300 chars -> acceptance_text_too_long
Public consumers reading the exported 128 constant will build a 129-char id and be surprised it validates. Suggestion: pass ACCEPTANCE_MAX_ID_LENGTH for acceptanceId/spaceId/decidedBy/positionId/turnId, or delete the constant. Add a boundary test at 128/129.
3. Should-fix — decidedAt accepts non-ISO-8601 strings via Date.parse.
iso8601 (acceptance.ts:151-160) only rejects when Date.parse is NaN, which is very permissive:
"2026-10-01" -> OK (date-only, not a timestamp)
"2026" -> OK
"1" -> OK (parsed as year 2001)
"2026-02-30T00:00:00Z" -> OK (Feb 30 does not exist)
"Fri Oct 02 2026" -> OK (local-format)
goals.ts already has a stricter calendarDate helper (line 287-295) that round-trips through toISOString. For an audit trail of "who accepted, when", the record should pin a real instant. Suggestion: require an ISO-8601 instant (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/) and round-trip check, mirroring goals.ts.
4. Minor — acceptance_invalid is overloaded across many distinct triggers.
The code covers: non-object input, non-array verdicts, verdict count over ACCEPTANCE_MAX_VERDICTS, non-integer/negative criteriaIndex, non-boolean passed, non-string note, control characters, whitespace in an identifier, and bad decidedAt. The sibling goals.ts splits these out (goal_too_many_items, goal_invalid_transition). Callers cannot distinguish "too many verdicts" from "criterion index must be non-negative" without string-matching the message. Suggestion: add a dedicated acceptance_too_many_verdicts (matching the goals.ts convention) at minimum, and consider splitting "must be a string/boolean" type errors from value errors.
5. Minor — acceptance_unknown_field also fires on missing fields and non-objects.
keysMatch combines "no unknown key" and "all required keys present", so a missing spaceId and a non-object null/[] both return acceptance_unknown_field. This matches an existing house convention in goals.ts (verified: missing title -> goal_unknown_field), so it is not new — but since the prompt flags this class, worth noting for a v2: a missing-field case would read better as acceptance_invalid or a dedicated acceptance_missing_field.
6. Minor — control-character check misses DEL and C1.
nonEmptyText (line 121) and parseNote (line 266) test charCodeAt(0) < 0x20, so U+007F (DEL), U+0085 (NEL), and U+009F pass through into ids/paths/notes. U+202E (RTL override) also passes and could spoof a display of spaceId. Verified: spaceId "a\u007fb" -> OK, "a\u0085b" -> OK, "a\u009fb" -> OK. Suggestion: extend the predicate to < 0x20 || (>= 0x7f && <= 0x9f) and consider rejecting bidi controls (\u200e, \u200f, \u202a-\u202e, \u2066-\u2069).
7. Minor — evidencePath/artifactPath are unvalidated strings.
pathText (line 136) only reuses nonEmptyText with the 1024 bound; no separator/traversal/absolute-path policy. evidencePath: "../../etc/passwd" validates OK. If a consumer later resolves these against the space folder, that is a traversal primitive. The contract comment says "pointer to the artifact"; pin it down (relative, no .., no leading /) or explicitly document that the field is opaque and must be re-validated by the consumer.
Test coverage
17 cases, all pass (I ran them against the real module). Mutations confirmed the suite genuinely pins: duplicate detection, the all-passed requirement, the missing-note requirement, whitespace-in-identifier, the schemaVersion gate, the integer check, and the unknown-field/missing-required behaviour.
Survivors — behaviors with no asserting case (mutating the code kept all 17 green):
- the
ACCEPTANCE_MAX_VERDICTS(64) bound — never exercised (issue #4 ties in here); - the control-character rejection in both
nonEmptyTextandparseNote(issue #6); - the
iso8601Date.parserejection path (issue #3); pathText's 1024 bound andACCEPTANCE_MAX_TEXT_LENGTHonnote.
Two cases in the title list are thin: "whitespace id" only covers the interior-space path, not a whitespace-only id ("" -> acceptance_empty_string) or a Unicode-space id (U+00A0/U+3000 reject correctly, U+200B currently slips through). Suggest adding: exactly-64 vs 65 verdicts; a control char in an id/path/note; a malformed decidedAt; a note at 4096/4097; and the 128/129 id boundary from issue #2.
Verdict
COMMENT — no blockers for the packaging/layout path (constraint 1 is clean and the CJK gate is unaffected). Recommend addressing issues #1 (unenforced "every criterion" invariant — it defeats the purpose of the record) and #2 (dead 128 bound) before building the space UI on top. Issues #3–#7 are worth fixing while the contract is still unreleased and cheap to change.
Summary
Adds the unreleased
acceptance.v1contract for a human verdict on completed work in a collaboration space. Approval remains permission to act; this contract describes acceptance of a deliverable. This is a shared contract plus tests, with no persisted store or renderer.An accepted record must contain one passed verdict for each index
0..criteriaCount-1.criteriaCountis required on records and create requests, bounded to 64, and must come from the plan being accepted. Consumers must compare it with the authoritative document revision: validation checks the record shape, not caller identity or authority. Rejected records require a nonblank note.Review corrections
./acceptanceexport remain registered; CHANGELOG has an Unreleased entry.Validation ledger
Environment: macOS arm64, Node 24.14.1, npm 11.11.0. No external service or credential is required. From the repository root, install dependencies with
npm ci; the complete UI check also needs the CI-pinned design-system checkout at4a82fce41f89f1a55b6b02cdb54f7a4e9ce9d84d, built in the adjacentdesign-systemfolder.npm run buildnode --test apps/server/dist/test/acceptance.test.jsnpm run prepare:updater && node --test scripts/test/package-layout.test.mjsnpm audit --audit-level=highand fail on existinghttp-cache-semantics@4.2.0(GHSA-ch52-4w7c-c8xp). The other 9 checks, including CodeQL, passCompatibility, limitations and rollback
criteriaCountis a required field added before v1 ships; the stacked UI PR #560 has been updated with it. No released persistence is migrated. This contract does not enforce theGoalWorkItemstate transition or authenticate an acceptor. Those belong to future consumers. Real Windows execution was not reproduced locally; hosted CI covers it. Reverting the additive contract and its inventory/export/test entries rolls back this slice.Refs #558. Merge this contract before the UI; #560 is stacked onto this branch and remains a draft until document/persisted-verdict integration is complete.
Current shared audit blocker: GHSA-ch52-4w7c-c8xp. A separate 4.3.0 experiment clears npm's version-range check but still reproduces the shared-cache reuse issue, so it was not published as a security repair. No audit threshold or required check was weakened.
2026-10-05 follow-up
The current head additionally contains the dependency removal tracked in #562: app-builder-lib now uses the official @electron/get 5.1.0 already used by Electron, removing got/cacheable-request/http-cache-semantics instead of merely escaping the advisory range. Minimum build Node is 22.12.0; CI still runs Node 24 and the unchanged high-severity audit. Legacy Got download options and GLOBAL_AGENT_* proxy options are not supported by get 5; repository release commands use the existing native Electron distribution.
Fresh local validation after this dependency change: npm ci --ignore-scripts --no-audit --no-fund (exit 0), node --test scripts/test/electron-download.test.mjs (2/2, real loopback artifact download, checksum verification, validated cache reuse, corrupt SHA256 rejection), npm run security:check (exit 0; no high/critical findings, two existing moderate Vitest findings). The earlier feature-specific results above describe the feature implementation; earlier red audit CI refers to the previous head. Fresh complete current-head CI is running and has not yet been declared passed. Another maintainer must review the current head; no self-approval or branch-protection bypass.
Connection-draft prerequisite and failed-CI follow-up
The prior macOS run at 6b7819c failed four renderer tests: a real missing-PAT assertion in the old ServiceConnections implementation, two App navigation timeouts (5000ms) and a subsequent missing-tree assertion. The acceptance contract's own 24 tests passed. Rather than retrying to hide PAT loss, this head merges the already verified #513 fix. Merge #513 before this contract PR to keep the baseline dependency clear. The contract API itself is unchanged by this follow-up.
Fresh local renderer typecheck passed; App plus service-connections regression suites passed 118/118, including the workspace/model race cases that timed out in that macOS run. No assertion, exclusion or test timeout was weakened. Fresh full CI is running for the updated head.
Current-head CI result
All 11 checks passed at
0a5479c4078e4cdc50dfc46d141638df171b71b8, including both full Node 24 jobs, required security checks, staging, installers and layout parity: verify. The failure at the preceding head is retained above; it is not the current CI result. Independent current-head maintainer approval remains required.