`parseAppearanceAction`, `parseSettingState`, and `summarizeCommandAttemptFailures`
existed twice, in `platform-apple/src/core/settings-parsing.ts` and
`platform-android/src/settings-parsing.ts`. The two modules differed only by four
`fallow-ignore-next-line code-duplication` comments, and their test files differed
only by the import path of one assertion helper — the duplication was silenced
rather than resolved.
Both now consume one declaration:
- the appearance and state vocabularies move to `@agent-device/contracts/settings`,
beside `parsePermissionAction` and `parseTextSizeCategory`, which already share
`findVocabularyName` and the same refusal shape;
- the command-attempt summary moves to `@agent-device/kernel/errors`, beside
`errorMessage`, which is what its payload travels with.
`SETTINGS_APPEARANCE_USAGE` is now built from the appearance vocabulary instead of
restating it, which the existing usage and macOS-refusal pins already prove is
byte-identical. A third hand copy in `src/commands/capture/settings.ts` derives its
membership set from the same declaration, matching how that file already builds the
permission sets, and a compile-time pin keeps the vocabulary equal to the published
client union without changing how that union is spelled in the shipped `.d.ts`.
Acceptance is unchanged on every route: the state parser keeps lowercase-only
matching, so padding that used to be refused is still refused.
Summary
parseAppearanceAction,parseSettingState, andsummarizeCommandAttemptFailuresexistedtwice, in
platform-apple/src/core/settings-parsing.tsandplatform-android/src/settings-parsing.ts.diffshows the two modules differed only by fourfallow-ignore-next-line code-duplicationcomments, and their test files differed only by theimport path of one assertion helper: the duplication was silenced rather than resolved, which is
exactly the "do not add guards that reconstruct another source of truth" trap.
One declaration now serves both owners:
@agent-device/contracts/settings, besideparsePermissionAction/parseTextSizeCategory, which already sharefindVocabularyNameand the same
INVALID_ARGSrefusal shape;@agent-device/kernel/errors, besideerrorMessage.A third copy existed in
src/commands/capture/settings.ts, hand-listing'light', 'dark', 'toggle'into its membership set. It now derives from the vocabulary, matchinghow that file already builds
setOf(...PERMISSION_ACTIONS).SETTINGS_APPEARANCE_USAGEis builtfrom the vocabulary too instead of restating it.
Acceptance is unchanged on every route. The state parser deliberately keeps lowercase-only
matching (no trim), so padding that used to be refused still is — pinned explicitly. 12 files,
net −8 production lines, and three duplicated sources of truth become one.
Validation
Tested at
55a2b905e.pnpm check:affected --run— all runnable checks passed.pnpm check:fallow --base origin/main— no issues in 13 changed files. Before the CLIderivation it flagged
APPEARANCE_ACTIONSas an export with no consumer; deriving the CLI setfrom it resolved that at the owner instead of suppressing it. The four duplication
suppressions are gone and no baseline entry referenced the deleted paths.
settings.test.tsasserts the literal
'appearance <light|dark|toggle>'inSETTINGS_FORMSand in the macOSrefusal message, so deriving the usage string from the vocabulary cannot silently move a byte.
vitest --project unit-coreovercontracts/src/settings,kernel/src/errors,platform-apple …/app-settings,platform-android …/settings*,src/commands/capture,settings,cli-help— 25 files / 394 tests, plus the moved cases at their new owners.oxlint . --deny-warnings, repo-wideoxfmt,tsc -bfor the four packages, roottsc -p tsconfig.json.No device lane is selected: no argv, wire shape, or route changes — the same values reach the
same tools.