${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 ? `
Look at:${q.links.map(linkOf).join('')}
` : ''}
@@ -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');