diff -ru r1tree/packages/bus/README.md verify/packages/bus/README.md --- r1tree/packages/bus/README.md 2026-10-14 10:53:39.557838852 -0500 +++ verify/packages/bus/README.md 2026-10-14 10:53:39.033151464 -0500 @@ -208,15 +208,16 @@ block's `by` names the acting role and refused when launch state is revoked. S6 still owns launch allowlists, capacity and actual spawning. -`decision.raise` checks its choices in this order, and each refusal names one field: +`decision.raise` checks its choices in this order, and each of these codes names one field: | Code | Field | Refused when | |---|---|---| -| `invalid-options` | `options` | fewer than 2 or more than 9 options, an option that isn't `{key,text}` with a valid key and text, or duplicate keys | +| `invalid-options` | `options` | not an array, fewer than 2 or more than 9 options, an option whose `key` isn't 1 to 160 letters, digits or `_.:/-` starting with a letter or digit, or whose `text` isn't a string of 1 to 1024 characters with no NUL, or duplicate keys | | `invalid-recommendation` | `recommendation` | the value isn't exactly one option key; `"yes: a reason"` refuses, so the reason goes in `question` | | `invalid-approval-choice` | `approvalChoice` | the value, or the default `yes` when it is omitted, isn't an option key | The options come first, so a bad option list refuses `invalid-options` even when the recommendation also misses. +An option that isn't an object with exactly `key` and `text` refuses `invalid-request`, like any other malformed argument. Authorization requires the resolved option identified by `approvalChoice` (default `yes`, which must exist), the same raiser role/run, action and target. diff -ru r1tree/packages/bus/tests/broker.test.mjs verify/packages/bus/tests/broker.test.mjs --- r1tree/packages/bus/tests/broker.test.mjs 2026-10-14 10:53:39.557838852 -0500 +++ verify/packages/bus/tests/broker.test.mjs 2026-10-14 10:53:39.033151464 -0500 @@ -176,6 +176,11 @@ () => raise(b, c, 'prd.approve', { options: [{ key: 'yes', text: 'a' }], recommendation: 'no' }), /invalid-options/, ); + const shaped = (o) => raise(b, c, 'prd.approve', { options: [{ key: 'yes', text: 'a' }, o], recommendation: 'no' }); + assert.throws(() => shaped({ key: 'no bad', text: 'b' }), /invalid-options/); + assert.throws(() => shaped({ key: 'no', text: '' }), /invalid-options/); + for (const o of [{ key: 'no' }, { key: 'no', text: 'b', why: 'c' }, 'no']) + assert.throws(() => shaped(o), /invalid-request/, 'a malformed option object is a malformed argument'); assert.deepEqual(count(), before, 'a refused raise writes no decision and no message'); assert.equal(call(b, c, 'inbox').length, 0); const d = raise(b, c, 'prd.approve', { recommendation: 'yes' });