diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 2d9e4ec..af91fa5 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -191,7 +191,8 @@ /* questions: the choices are one list; the recommended one is marked in place */ .qcard h4 { font-size: 14px; font-weight: 600; margin: 8px 0 4px; line-height: 1.45; } - .qcard .ctx { color: var(--fg-2); margin: 4px 0 10px; } + .qcard .ctx { color: var(--fg-2); margin: 4px 0 10px; white-space: pre-wrap; } + .qcard .rewritten { color: var(--red-fg); font-weight: 600; margin: 0 0 8px; } .choices { display: grid; gap: 6px; margin: 10px 0 8px; } .choice { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 4px 10px; align-items: start; text-align: left; height: auto; margin: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line-2); background: var(--surface); color: var(--fg); font-weight: 400; cursor: pointer; } .choice:hover { background: var(--hover); opacity: 1; } @@ -1019,6 +1020,7 @@

Checks before merge the automated return `
${cardHead(KIND_ICON[q.kind] || 'circle-help', `${esc(KIND_WORDS[q.kind] || words(q.kind))}${q.goal_hold === 'park' ? 'its goal is parked' : ''}`, { extra: (q.blocks || []).length ? `holds up ${q.blocks.map(tlink).join(', ')}` : q.ticket ? `about ${tlink(q.ticket)}` : '', id: q.id, rows: [['Asked', esc(when(q.asked_at))], ...(q.class ? [['Kind', esc(q.class)]] : [])] })} + ${q.rewritten ? '

This question changed after you answered it, so that answer no longer counts. Read it again before you answer: the supervisor\'s log was rewritten, which it should never do.

' : ''}

${linkify(q.question)}

${q.context ? `

${linkify(q.context)}

` : ''} ${(q.links || []).length ? `` : ''} @@ -1158,6 +1160,7 @@

${linkify(q.question)}

return `
${cardHead(KIND_ICON.review, 'Review', { extra: (q.blocks || []).length ? `holds up ${q.blocks.map(tlink).join(', ')}` : q.ticket ? `about ${tlink(q.ticket)}` : '', id: q.id, rows: [['Asked', esc(when(q.asked_at))], ...(q.class ? [['Kind', esc(q.class)]] : [])] })} + ${q.rewritten ? '

This question changed after you answered it, so that answer no longer counts. Read it again before you answer: the supervisor\'s log was rewritten, which it should never do.

' : ''}

${linkify(q.question)}

${q.context ? `

${linkify(q.context)}

` : ''}
${rv.data ? `${esc(rv.data.rows)} rows${rv.data.proposals ? `, ${esc(rv.data.proposals)} with a proposal` : ''} · ` : ''}page ${esc(String(rv.page?.sha256 ?? '').slice(0, 12))} ${ok ? 'verified' : `${esc(q.result_status || 'missing')}`}${rv.allow_partial ? ' · partial answers allowed' : ''}${pr && pr.total ? ` · ${esc(pr.done)} of ${esc(pr.total)} decided` : ''}
@@ -2703,6 +2706,8 @@

Changelog

${merged.length ? `
    ${merged.map((t) => `
  • ${tlink(t.id)} showTab(TAB); async function post(action, body, errId) { + // An answer names the question as this page showed it; a question changed since is refused. + if (action === 'answer' && body.seen === undefined) body = { ...body, seen: (DATA.questions || []).find((x) => x.id === body.id)?.hash }; const r = await fetch(`/api/${action}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); const out = await r.json(); if (!r.ok) { diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json index bb621e6..3087a66 100644 --- a/docs/devteam/schema/log.schema.json +++ b/docs/devteam/schema/log.schema.json @@ -1469,6 +1469,12 @@ "const": true, "description": "On the owner's answer to a review: sent back without a result; the answer says why.", "x-tip": "You sent the review back without a result." + }, + "question_hash": { + "type": "string", + "pattern": "^[0-9a-f]{16}$", + "description": "On the owner's answer: the hash of the question as the owner saw it (every field it was asked with). An answer whose question no longer has this hash answers nothing: the question was changed after the owner answered it, so it is open again.", + "x-tip": "The question as you saw it when you answered." } }, "additionalProperties": false, @@ -1479,7 +1485,8 @@ "x-set-only": [ "secrets_posted", "result", - "declined" + "declined", + "question_hash" ] }, "decision": { @@ -1988,7 +1995,8 @@ "standing_rule", "secrets_posted", "result", - "declined" + "declined", + "question_hash" ] }, "knowledge": { diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 9e7da22..6293055 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -850,6 +850,7 @@ function fold(all) { if (l.op !== 'new') continue; if (objects.has(l.id)) { ignored.push({ line: l, why: 'duplicate id' }); + if (l.type === 'question') Object.defineProperty(objects.get(l.id), '__duplicate', { value: true }); // not copied into views or JSON continue; } const { v, op, __file, __line, at, by, ...body } = l; @@ -1105,6 +1106,11 @@ function appendLog(fields, by = logWriter()) { const cur = logGet(fields.id); if (cur && (type === 'feature' ? cur.id : cur.feature) !== f) die(`${by} may only change the feature "${f}" and its tickets`); } + // The owner's answer binds to the question as it stands (questionView). + if (type === 'question' && fields.op === 'set' && writerClass(by) === 'owner' && fields.answer !== undefined) { + const q = logGet(fields.id); + if (q?.type === 'question') fields = { ...fields, question_hash: questionHash(q) }; + } const file = logFileFor(by, type); const prefix = SCHEMA['x-id'].prefix[type]; const build = (at) => { @@ -1184,9 +1190,22 @@ const upkeepGoal = () => [...readLog().objects.values()].find((o) => o.type === const logGet = (id) => readLog().objects.get(id) ?? null; const byTime = (a, b) => a.created_at.localeCompare(b.created_at); +// What the owner saw: the question with every field it was asked with (later lines may only add +// the supervisor's status and reason, and the owner's own fields). The owner's answer records this +// hash, and an answer whose question no longer hashes the same answers nothing: the question was +// rewritten after the owner answered it (its line edited, or a backdated duplicate taking its +// place), so it is open again, and the owner answers what it says now. +const QUESTION_LATER = ['status', 'reason', 'answer', 'said', 'autonomy', 'standing_rule', 'secrets_posted', 'result', 'declined', 'question_hash']; +const questionHash = (q) => crypto.createHash('sha256').update(canonical(Object.fromEntries(Object.entries(q).filter(([k]) => !k.startsWith('__') && !['updates', 'created_at', 'created_by', 'channel', ...QUESTION_LATER].includes(k))))).digest('hex').slice(0, 16); + // Folded questions in the shape the inbox uses. function questionView(q) { - const ans = q.updates.filter((u) => u.answer !== undefined && writerClass(u.by) === 'owner').pop(); + const hash = questionHash(q); + const last = q.updates.filter((u) => u.answer !== undefined && writerClass(u.by) === 'owner').pop(); + // An answer from before answers carried the hash (no question_hash) still counts, unless a + // second line for the question exists: then nothing shows which one the owner saw. + const rewritten = !!last && (last.question_hash ? last.question_hash !== hash : !!q.__duplicate); + const ans = rewritten ? null : last; const st = q.updates.filter((u) => u.status !== undefined).pop(); let status = 'open'; let answer = null; @@ -1215,7 +1234,7 @@ function questionView(q) { question: q.question, context: q.context ?? '', options: q.options ?? [], choices: q.choices ?? [], links: q.links ?? [], goal: q.goal ?? null, change: q.change ?? null, rule_scope: q.rule_scope ?? null, recommendation: q.recommendation ?? '', confidence: q.confidence ?? null, default: q.default ?? null, deadline: q.deadline ?? null, blocks: q.blocks ?? [], approves: q.approves ?? [], evidence: q.evidence ?? [], note: q.note ?? null, why_asked: q.why_asked ?? null, secrets: (q.secrets ?? []).map((x) => ({ ...x, to: sinkWords(x.sink), sink_type: SINK_RE.exec(x.sink ?? '')?.[1] ?? null, warning: sinkWarning(x.sink) })), secrets_posted: q.updates.filter((u) => u.secrets_posted !== undefined && writerClass(u.by) === 'owner').pop()?.secrets_posted ?? [], - status, answer, answered_by: answeredBy, answered_at: answeredAt, matched, + status, answer, answered_by: answeredBy, answered_at: answeredAt, matched, hash, rewritten, autonomy: ans ? (q.autonomy ?? null) : null, standing_rule: ans ? (q.standing_rule ?? null) : null, channel: ans?.channel ?? null, said: ans?.said ?? null, review: review?.review ?? null, result: review?.result ?? null, declined: review ? review.declined : null, allow_partial: review ? review.allow_partial : null, result_status: review?.result_status ?? null, }; @@ -1431,6 +1450,17 @@ function cmdLogSync() { } // Validate every line (live and durable) and list what the fold ignored. +// A second line for a question replaces it if backdated, and a question changed after the owner +// answered it has lost that answer (questionView): both are the log being rewritten. +// The rewritten question fails until the owner answers what it says now; the leftover line then +// warns (the log is append-only, so it can't be removed). +function questionRewrites() { + const errors = []; + const warnings = []; + for (const q of logObjects('question')) if (questionView(q).rewritten) errors.push(`${q.id}: the question changed after the owner answered it, so that answer no longer counts and it is open again`); + for (const i of readLog().ignored) if (i.line.type === 'question' && i.why === 'duplicate id') warnings.push(`${i.line.id}: a second question line with this id (${i.line.__file}:${i.line.__line}); a question is asked once`); + return { errors, warnings }; +} function cmdLogCheck() { const names = new Set(); for (const d of [liveLogDir(), durableLogDir()]) if (fs.existsSync(d)) for (const f of fs.readdirSync(d)) if (f.endsWith('.jsonl')) names.add(f); @@ -1442,6 +1472,9 @@ function cmdLogCheck() { n += r.lines.length; } for (const i of readLog().ignored) out(`ignored: ${i.line.__file}:${i.line.__line} ${i.line.id}: ${i.why}`); + const rw = questionRewrites(); + for (const w of rw.warnings) out(`warn: ${w}`); + fails.push(...rw.errors); report(fails, `${n} log line(s)`); } @@ -2111,6 +2144,7 @@ function cmdValidate() { } for (const e of readLog().errors) warnings.push(`log ${e}`); + { const r = questionRewrites(); errors.push(...r.errors); warnings.push(...r.warnings); } for (const i of readLog().ignored) if (['ticket', 'feature'].includes(i.line.type) && i.why === 'duplicate id') errors.push(`${i.line.id}: a second ${i.line.type} line with this id (${i.line.__file}:${i.line.__line}) was ignored; use ${i.line.type} set`); const raw = ladder().raw_ceilings; for (const [c, v] of Object.entries(loadSettings().autonomy_ceilings ?? {})) { @@ -3602,6 +3636,7 @@ function cmdAnswer() { } const { by } = fromChat ? { by: 'owner' } : ownerWriter(); const v = openQuestion(id, ['open', 'defaulted']); + if (flagStr('seen') && flagStr('seen') !== v.hash) die(`${id} changed since you saw it; reload and read it again before you answer`); if (v.kind === 'goal-approval') die(`${id} approves a goal on its design: the owner answers it with goal decide ${v.goal} approve|refine|let-go [--notes "…"] (or on the dashboard)`); // A review is answered by the owner's own process with its result, or sent back with a reason; // never in free text, never from chat (a chat can't carry rows, and the supervisor must not @@ -6605,7 +6640,7 @@ async function cmdDashboard() { answer: (b) => { if (!ID.test(b.id ?? '') || !text(b.answer)) return null; if (b.decline !== undefined && b.decline !== true) return null; - const args = ['answer', b.id, b.answer.trim(), ...autonomyArgs(b)]; + const args = ['answer', b.id, b.answer.trim(), ...autonomyArgs(b), ...(/^[0-9a-f]{16}$/.test(b.seen ?? '') ? ['--seen', b.seen] : [])]; // "Send it back" on a review: the owner's reason, no result if (b.decline === true) return [...args, '--decline']; if (text(b.rule, 600)) args.push('--rule', b.rule.trim()); diff --git a/test/kit.test.mjs b/test/kit.test.mjs index 772ee45..15fde84 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -977,7 +977,60 @@ test('devteam kit', async (t) => { assert.match(dt(['merge', '--chore', 'rules', '--dry-run', '--approved', Qn, '--title', 'chore-rules: tighten', '--body-file', body], { role: 'sup' }).err, /was answered "no, leave quality\.md alone", not its recommendation \("yes"\): that approves nothing/); const Qg = must(['ask', '--question', 'Add the extra rule to quality.md?', '--class', 'governance-edit', '--reasons', 'owner-only', '--recommendation', 'yes'], { role: 'sup' }); must(['answer', Qg, 'yes']); - must(['merge', '--chore', 'rules', '--dry-run', '--approved', Qg, '--title', 'chore-rules: tighten', '--body-file', body], { role: 'sup' }); + const mergeArgs = ['merge', '--chore', 'rules', '--dry-run', '--approved', Qg, '--title', 'chore-rules: tighten', '--body-file', body]; + must(mergeArgs, { role: 'sup' }); + // The answer binds to the question as the owner saw it. The supervisor writes its own file, so it + // could put a backdated second line for that id (which the fold would take as the question) or + // edit the line in place: either way the answer no longer counts, the question is open again, + // and log-check fails. + const logDir = path.join(repo, 'docs/devteam/run/log'); + const supFile = path.join(logDir, fs.readdirSync(logDir).find((f) => f.startsWith('supervisor-'))); + const kept = fs.readFileSync(supFile, 'utf8'); + const orig = kept.split('\n').filter(Boolean).map((l) => JSON.parse(l)).find((l) => l.id === Qg && l.op === 'new'); + const wider = 'Add the extra rule to quality.md, and drop every gate?'; + const rewrites = { + 'a backdated second line': kept + line({ ...orig, at: new Date(Date.parse(orig.at) - 10).toISOString(), question: wider }), + 'the line edited in place': kept.replace(JSON.stringify(orig.question), JSON.stringify(wider)), + }; + for (const [how, text] of Object.entries(rewrites)) { + fs.writeFileSync(supFile, text); + assert.match(dt(mergeArgs, { role: 'sup' }).err, /is not a question the owner answered/, how); + const v = json(must(['questions', '--id', Qg, '--json']))[0]; + assert.equal(v.status, 'open', how); + assert.equal(v.rewritten, true, how); + assert.equal(v.question, wider, how); + const lc = dt(['log-check']); + assert.notEqual(lc.status, 0, how); + assert.match(lc.out, /changed after the owner answered it/, how); + if (how === 'a backdated second line') assert.match(lc.out, /a second question line with this id/); + } + fs.writeFileSync(supFile, kept); + must(mergeArgs, { role: 'sup' }); + // an answer from before answers carried the hash still counts, unless a second line for the + // question exists: then nothing shows which one the owner saw + const ownFile = fs.readdirSync(logDir).filter((f) => f.startsWith('owner-')).map((f) => path.join(logDir, f)).find((f) => fs.readFileSync(f, 'utf8').includes(Qg)); + const ownKept = fs.readFileSync(ownFile, 'utf8'); + fs.writeFileSync(ownFile, ownKept.split('\n').map((l) => { if (!l) return l; const o = JSON.parse(l); if (o.id !== Qg || !o.question_hash) return l; delete o.question_hash; return JSON.stringify(o); }).join('\n')); + must(mergeArgs, { role: 'sup' }); + fs.writeFileSync(supFile, rewrites['a backdated second line']); + assert.match(dt(mergeArgs, { role: 'sup' }).err, /is not a question the owner answered/, 'a legacy answer and a second line'); + fs.writeFileSync(ownFile, ownKept); + fs.writeFileSync(supFile, kept); + // once the owner answers the question as it now reads, the leftover line only warns + fs.writeFileSync(supFile, rewrites['a backdated second line']); + must(['answer', Qg, 'yes']); + const after = dt(['log-check']); + assert.equal(after.status, 0, after.out + after.err); + assert.match(after.out, /warn: .*a second question line with this id/); + fs.writeFileSync(supFile, kept); + fs.writeFileSync(ownFile, ownKept); + must(mergeArgs, { role: 'sup' }); + // the dashboard sends the hash of the question it showed: an answer to one changed since is refused + const Qs = must(['ask', '--question', 'Keep the extra rule?', '--class', 'governance-edit', '--reasons', 'owner-only', '--recommendation', 'yes'], { role: 'sup' }); + const seen = json(must(['questions', '--id', Qs, '--json']))[0].hash; + assert.match(seen, /^[0-9a-f]{16}$/); + assert.match(dt(['answer', Qs, 'yes', '--seen', '0000000000000000']).err, /changed since you saw it/); + must(['answer', Qs, 'yes', '--seen', seen]); git(['worktree', 'remove', '--force', wt('chore-rules')]); }); @@ -4074,7 +4127,8 @@ test('link probes: four at a time inside a tick budget, never a prompt, and a pa // 401/403/405 is a page behind auth: a low note, never a high finding that wakes wait edit((j) => { j.links = { locked: url('locked', 'auth'), gone: url('gone', 'gone'), console: { kind: 'repo', title: 'Console', remote: 'usbclub/console', path: co } }; }); g(['commit', '-qam', 'links 2'], old); g(['push', '-q', 'origin', 'main'], old); - tick({ PATH: `${shim}${path.delimiter}${process.env.PATH}` }); + // these probes are all quick: a long budget only stands in for a loaded machine + tick({ PATH: `${shim}${path.delimiter}${process.env.PATH}`, DEVTEAM_PROBE_BUDGET_MS: '120000' }); const fs2 = json(ok(old, ['metrics', '--json'])).findings; assert.equal(fs2.find((f) => f.subject === 'locked')?.kind, 'link-behind-auth'); assert.equal(fs2.find((f) => f.subject === 'locked').severity, 'low');