From ded3208264753cbb8712187574e7c9fcea53991b Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 7 Nov 2026 16:53:35 +0800 Subject: [PATCH] fix(host): keep the meeting list alive when a stored level does not parse (#2592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `summary()` is the projection every meeting action answers through, and it called the strict `decisionLevel()` on the stored record. A `level` no writer stores — a hand edit, or a record from an older build — made `meeting action=list` throw `meeting/invalid: level must be one of ordinary, major, severe` and lose every record, not only the bad one. The read side now renders an unknown level as `ordinary`, the way `advanceRefusal()` and the board's `decisionCardOf` already read it; the write path still refuses. Fixes #2592 Docs-not-needed: a read-side guard on a stored field; the write path still refuses an unknown level --- core/src/host/meetings.js | 8 ++- core/test/meetings-unknown-level.test.js | 53 +++++++++++++++++++ patches/ds-bot/files/src/host/meetings.js | 8 ++- .../files/test/meetings-unknown-level.test.js | 53 +++++++++++++++++++ 4 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 core/test/meetings-unknown-level.test.js create mode 100644 patches/ds-bot/files/test/meetings-unknown-level.test.js diff --git a/core/src/host/meetings.js b/core/src/host/meetings.js index a6eed6405..89c656450 100644 --- a/core/src/host/meetings.js +++ b/core/src/host/meetings.js @@ -498,8 +498,12 @@ export function install(rt) { const head = `${meeting.id} [${meeting.status}] ${meeting.question}` const lines = [head, `positions ${positions}/${meeting.participants.length}${meeting.owner ? ` | owner ${meeting.owner}` : ''}`] // #1831: the card's level and recipients are part of what the caller is told, and a severe - // card says in the summary why it will not advance on its own. - lines.push(`level: ${decisionLevel(meeting.level)}${(meeting.recipients ?? []).length === 0 ? '' : ` | recipients: ${(meeting.recipients ?? []).join(', ')}`}`) + // card says in the summary why it will not advance on its own. #2592: this is the projection + // every action answers through, so a stored level no writer stores — a hand edit, or a record + // from an older build — renders as `ordinary`, the way the sibling readers treat it, instead + // of throwing the whole list away. The strict validator stays on the write path. + const level = DECISION_LEVELS.includes(meeting.level) ? meeting.level : 'ordinary' + lines.push(`level: ${level}${(meeting.recipients ?? []).length === 0 ? '' : ` | recipients: ${(meeting.recipients ?? []).join(', ')}`}`) const paused = advanceRefusal(meeting) if (paused !== undefined) lines.push(`paused: ${paused}`) if (meeting.dissent !== undefined) lines.push(`dissent: ${meeting.dissent.text} — ${meeting.dissent.owner}`) diff --git a/core/test/meetings-unknown-level.test.js b/core/test/meetings-unknown-level.test.js new file mode 100644 index 000000000..0f632150d --- /dev/null +++ b/core/test/meetings-unknown-level.test.js @@ -0,0 +1,53 @@ +// #2592: `summary()` is the projection every meeting action answers through, and it called the +// strict `decisionLevel()` on the stored record. A `level` no writer stores — a hand edit, or a +// record from an older build — made `meeting action=list` throw `meeting/invalid` and lose every +// record, not only the bad one. The read side now renders an unknown level as `ordinary`, the way +// `advanceRefusal()` and the board's `decisionCardOf` already read it; the write path still refuses. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { install } from '../src/host/meetings.js' + +const corrupt = { + id: 'm1', + status: 'decided', + question: 'Adopt the record?', + level: 'critical', + participants: [{ name: 'Dev', position: { stance: 'agree' } }], + owner: 'Chief', + decisionFile: 'decisions/x.md', + taskIds: [], + decidedAt: 12, + updatedAt: 12, +} + +const boot = (meetings = {}) => { + const rt = { + state: { meetings }, + load: async () => {}, + save: async () => {}, + ctx: { tools: { register() {} } }, + } + install(rt) + return rt +} + +test('#2592: the meeting list keeps its records when a stored level does not parse', async () => { + const rt = boot({ m1: corrupt }) + const list = await rt.meetingEndpoint('list') + assert.match(list, /m1 \[decided\] Adopt the record\?/) + assert.match(list, /level: ordinary/) +}) + +test('#2592: reading the record itself does not throw either', async () => { + const rt = boot({ m1: corrupt }) + const read = await rt.meetingEndpoint('read', { meeting: 'm1' }) + assert.match(read, /m1 \[decided\] Adopt the record\?/) +}) + +test('#2592: the write path still refuses an unknown level', async () => { + const rt = boot() + await assert.rejects( + rt.meetingEndpoint('open', { question: 'Adopt the record?', participants: ['Dev'], level: 'critical' }), + /invalid: level must be one of ordinary, major, severe/, + ) +}) diff --git a/patches/ds-bot/files/src/host/meetings.js b/patches/ds-bot/files/src/host/meetings.js index a6eed6405..89c656450 100644 --- a/patches/ds-bot/files/src/host/meetings.js +++ b/patches/ds-bot/files/src/host/meetings.js @@ -498,8 +498,12 @@ export function install(rt) { const head = `${meeting.id} [${meeting.status}] ${meeting.question}` const lines = [head, `positions ${positions}/${meeting.participants.length}${meeting.owner ? ` | owner ${meeting.owner}` : ''}`] // #1831: the card's level and recipients are part of what the caller is told, and a severe - // card says in the summary why it will not advance on its own. - lines.push(`level: ${decisionLevel(meeting.level)}${(meeting.recipients ?? []).length === 0 ? '' : ` | recipients: ${(meeting.recipients ?? []).join(', ')}`}`) + // card says in the summary why it will not advance on its own. #2592: this is the projection + // every action answers through, so a stored level no writer stores — a hand edit, or a record + // from an older build — renders as `ordinary`, the way the sibling readers treat it, instead + // of throwing the whole list away. The strict validator stays on the write path. + const level = DECISION_LEVELS.includes(meeting.level) ? meeting.level : 'ordinary' + lines.push(`level: ${level}${(meeting.recipients ?? []).length === 0 ? '' : ` | recipients: ${(meeting.recipients ?? []).join(', ')}`}`) const paused = advanceRefusal(meeting) if (paused !== undefined) lines.push(`paused: ${paused}`) if (meeting.dissent !== undefined) lines.push(`dissent: ${meeting.dissent.text} — ${meeting.dissent.owner}`) diff --git a/patches/ds-bot/files/test/meetings-unknown-level.test.js b/patches/ds-bot/files/test/meetings-unknown-level.test.js new file mode 100644 index 000000000..0f632150d --- /dev/null +++ b/patches/ds-bot/files/test/meetings-unknown-level.test.js @@ -0,0 +1,53 @@ +// #2592: `summary()` is the projection every meeting action answers through, and it called the +// strict `decisionLevel()` on the stored record. A `level` no writer stores — a hand edit, or a +// record from an older build — made `meeting action=list` throw `meeting/invalid` and lose every +// record, not only the bad one. The read side now renders an unknown level as `ordinary`, the way +// `advanceRefusal()` and the board's `decisionCardOf` already read it; the write path still refuses. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { install } from '../src/host/meetings.js' + +const corrupt = { + id: 'm1', + status: 'decided', + question: 'Adopt the record?', + level: 'critical', + participants: [{ name: 'Dev', position: { stance: 'agree' } }], + owner: 'Chief', + decisionFile: 'decisions/x.md', + taskIds: [], + decidedAt: 12, + updatedAt: 12, +} + +const boot = (meetings = {}) => { + const rt = { + state: { meetings }, + load: async () => {}, + save: async () => {}, + ctx: { tools: { register() {} } }, + } + install(rt) + return rt +} + +test('#2592: the meeting list keeps its records when a stored level does not parse', async () => { + const rt = boot({ m1: corrupt }) + const list = await rt.meetingEndpoint('list') + assert.match(list, /m1 \[decided\] Adopt the record\?/) + assert.match(list, /level: ordinary/) +}) + +test('#2592: reading the record itself does not throw either', async () => { + const rt = boot({ m1: corrupt }) + const read = await rt.meetingEndpoint('read', { meeting: 'm1' }) + assert.match(read, /m1 \[decided\] Adopt the record\?/) +}) + +test('#2592: the write path still refuses an unknown level', async () => { + const rt = boot() + await assert.rejects( + rt.meetingEndpoint('open', { question: 'Adopt the record?', participants: ['Dev'], level: 'critical' }), + /invalid: level must be one of ordinary, major, severe/, + ) +})