Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **4b.3 review round (independent Codex verification, verdict "NO" on six contract
gaps — all six accepted, fixed red-first; three claim-wording findings ruled
not-defects).** All CLI-enforced, each with a falsifier (W1–W6) seen red against
`main @ b029c81` before its fix:
- **Strict slots decode strictly (W1):** `parseIntent` now uses a fatal UTF-8 decode;
a slot with invalid bytes refuses as unrecoverable instead of being lossily
normalized into an operationId nobody published — and then cleared.
- **The receipt must carry the intent's tool (W2):** `validateMirrorReceipt` compares
`receipt.tool` to `intent.tool`; a post-state slot whose tool contradicts the
receipt it names is ambiguous, never completed.
- **Mirror validity means agreement, not linkage (W3):** the spec projection carries
status, severity, AND summary. The transition mirror op now writes all three
(summary was dropped), and the exact-repeat validity check compares all three
instead of counting linked rows — a stale-but-unique mirror is now trued by one
committed repeat, then repeats no-op again.
- **Doctor observes without recovering (W4, W4b):** the WAL diagnosis runs first, and
the writability probe no longer travels the write door at all — an existing store
is probed with a scratch file instead of `initProject`, whose lock acquisition ran
recovery. Previously doctor consumed the slot and reported "no pending intent"
about the store it had just changed; round 2 showed even a reordered probe loses a
slot that lands after the diagnosis sample, so the probe is now lock-free by
construction (W4b races a slot in after the sample and proves it survives).
- **Resource reads are byte-pure, enforced (W5):** the MCP read paths (state/ledger
resources, the receipt resource, `score.confidence`) now use pure peeks that never
create, back up, or reinitialize a record; an unprovable record answers the one
allowlisted MirrorUnrecoverable sentence. Previously a resource read over a
malformed ledger wrote a `.corrupt` backup file — a write on a read path.
- **CLEAR re-checks identity (W6):** the slot delete (spec CLEAR step) re-reads the
slot and refuses if the bytes are not the bytes this pass proved — on both the
transaction's own clear and recovery's fenced clear.
- **Round 2 of the same review added three more, fixed the same way:** the peeks
decode fatally like the slot parser — a record with invalid UTF-8 answers the
allowlisted sentence instead of serving a U+FFFD-normalized projection (W7); the
stale-mirror truing commit moves ONLY the mirror — no duplicate log/history line,
no restamped proof timestamp; "commits once solely to admit it" now means exactly
that (W3 extended); and the doctor probe race above (W4b).
- **Round 3 of the same review added two more, fixed the same way:** the strict
mirrored-write reads and recovery's two record parses decode fatally like
everything else (W8, W9) — previously a lawful write over a record carrying one
invalid byte would commit a U+FFFD-normalized serialization and settle state and
ledger in permanent disagreement with no pending intent left behind; and doctor's
missing-store branch no longer reaches `initProject` at all (W4c) — a missing
store probes its nearest existing ancestor, so no first store appearing in the
sample-to-probe window can be recovered by the diagnosis tool. Doctor no longer
creates the store as a side effect; the first write does.
- **Round 4 of the same review added two more, fixed the same way:** the ordinary
loaders joined the fatal-decode rule (W10, W11) — `readJson`'s fast-path peek
answers null on undecodable bytes, and `readJsonResilient` routes invalid UTF-8
through the same loud backup-then-reinitialize path it has always used for invalid
JSON, with the backup now preserving the exact original bytes; previously a lawful
ordinary write would silently serialize a U+FFFD-normalized record and settle the
two canonical files in disagreement with no backup and no intent. And the doctor
writability probe now proves directory creation as well as file creation (W13) —
Windows ACLs grant the two separately, the lock is a mkdir, and a file-only probe
answered "writable" where the first real write then failed. W12 additionally pins
recovery's own fatal decode against an adversarially consistent slot whose hash
certifies undecodable bytes.
- **Round 5 returned the review's YES** ("one operation, two files, one crash story —
the mirror guarantee holds"): both round-4 fixes verified, the decode sweep found
no remaining permissive parse on any canonical byte path, and the probe matrix
passed all four ACL shapes. Its one LOW — a half-failed doctor probe left its
scratch directory behind — is fixed (cleanup in a best-effort finally, W14).
- **Known limitation (parked, review round 2): the identity-checked clear is two
syscalls.** The CLEAR step re-reads and compares before deleting, exactly as the
spec words it — but compare and unlink cannot be one atomic operation through a
pathname API (no compare-and-unlink exists; same class as the 2.5 check→read gap).
A substitution landing in that window is deleted. Reaching it requires a writer
that bypasses the workspace lock — every lawful writer publishes and clears slots
under it — so this is recorded as a named limit, not repaired with machinery the
contract cannot honor.
- Ruled NOT defects, on the record: pre-commit `.tmp-` residue (documented inert by
design in 4b.1), CLI/MCP byte-equality (ids and receipts differ by ratified
design), and the MCP retry wording (the spec deliberately gives MCP the generic
`WriteFailed`; the CLI carries the "re-run" sentence). S20's loader count was
updated to the new peek — the one-snapshot contract it pins is unchanged and it
now additionally pins that reads never touch the creating loader.

<!-- Traced by: claude-fable-5 -->

### Added

- **Known limitation (parked, owner: Danny): a host-environment hold can stall the
Expand Down
22 changes: 16 additions & 6 deletions src/artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,10 @@ function mirrorOpFor(ledger, defect, now, mintId) {
const linked = String(defect.ledgerId || '');
const matches = linked ? (ledger.defects || []).filter((x) => x && x.id === linked) : [];
if (matches.length === 1) {
// The mirror follows the record on both axes the transitions own; carrying
// an unchanged value is a no-op inside the same replace.
const after = { ...matches[0], severity: defect.severity, status: defect.status || 'open', updatedAt: now };
// The mirror follows the record on every axis the spec projection names —
// status, severity, summary; carrying an unchanged value is a no-op inside
// the same replace.
const after = { ...matches[0], severity: defect.severity, summary: defect.summary, status: defect.status || 'open', updatedAt: now };
return { collection: 'defects', id: linked, mode: 'replace', after };
}
const mirror = {
Expand Down Expand Up @@ -427,8 +428,13 @@ function prepareDefectTransition(s, ledger, id, toStatus, meta, mintId) {
if (from === toStatus) {
const [recorded, offered] = transitionProof(d, toStatus, meta_);
const linked = String(d.ledgerId || '');
// Valid means TRUTHFUL, not merely linked: one mirror row that agrees on
// every axis the projection names. A stale-but-unique row would otherwise
// pass as valid forever, and the no-op below would refuse to repair it.
const rows = ledger ? (ledger.defects || []).filter((x) => x && x.id === linked) : [];
const mirrorValid = ledger
? linked && (ledger.defects || []).filter((x) => x && x.id === linked).length === 1
? Boolean(linked) && rows.length === 1 &&
rows[0].status === (d.status || 'open') && rows[0].severity === d.severity && rows[0].summary === d.summary
: true;
if (recorded === offered && mirrorValid) {
// The exact repeat, mirror already truthful: no log line, no history, no
Expand All @@ -442,8 +448,12 @@ function prepareDefectTransition(s, ledger, id, toStatus, meta, mintId) {
'replace the original. Reopen it first if the recorded proof is wrong.'
);
}
// Exact repeat but the mirror is missing or ambiguous: commit once solely
// to perform the D2b admission below.
// Exact repeat but the mirror is missing, ambiguous, or stale: commit once
// SOLELY to admit or true the mirror — the mirror op alone. The transition
// already happened; re-applying it would restamp proof-owned timestamps
// and append duplicate log/history lines, forging a second audit trail
// for one event (review round 2, finding 3).
return { kind: 'commit', record: d, ledgerOps: [mirrorOpFor(ledger, d, now, mintId)] };
}

d.status = toStatus;
Expand Down
51 changes: 40 additions & 11 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -804,17 +804,6 @@ function cmdDoctor(cwd, asJson) {
}
add('every skills/*/SKILL.md has frontmatter + description', skillProblems.length === 0, skillProblems.join('; '));

let stateDetail = '';
let stateOk = true;
try {
state.initProject(cwd);
stateDetail = state.projectDir(cwd);
} catch (e) {
stateOk = false;
stateDetail = e.message;
}
add('state dir writable', stateOk, stateDetail);

// 4b WAL slot — read-only diagnosis, never a repair. A recoverable slot is
// informational (any supported write recovers it on its way in); a slot
// strict recovery cannot prove legal is the operator's, by name.
Expand All @@ -836,6 +825,46 @@ function cmdDoctor(cwd, asJson) {
add('WAL intent slot', false, e.message);
}

// The writability probe never travels the write door — not even for a store
// that does not exist yet: initProject acquires the workspace lock, whose
// choke point RUNS recovery, and a first store created by another process
// in the sample-to-probe window would be recovered by the very tool that
// exists to report it (seen live in review round 3). An existing store is
// probed with a scratch file (named .tmp- like every inert residue); a
// missing store probes its nearest existing ancestor — the first write
// creates the store itself.
let stateDetail = '';
let stateOk = true;
try {
const dir = state.projectDir(cwd); // still names a store conflict out loud
let probeDir = dir;
while (!fs.existsSync(probeDir)) {
const up = path.dirname(probeDir);
if (up === probeDir) break;
probeDir = up;
}
// The probe proves BOTH creations a real writer needs: a directory (the
// lock is a mkdir; a missing store starts as one) and a file inside it —
// Windows ACLs grant these separately, and a file-only probe said
// "writable" where the first real write then failed its mkdir.
const scratch = path.join(probeDir, `.doctor-probe.tmp-${process.pid.toString(36)}`);
try {
fs.mkdirSync(scratch);
fs.writeFileSync(path.join(scratch, 'probe'), 'doctor writability probe\n');
} finally {
// Best-effort, always: a probe that failed half-way must not leave
// residue a cold-start scan could misread as an interrupted write.
try {
fs.rmSync(scratch, { recursive: true, force: true });
} catch (_e) { /* the writability answer stands either way */ }
}
stateDetail = probeDir === dir ? dir : `${dir} (not created yet — the first write creates it)`;
} catch (e) {
stateOk = false;
stateDetail = e.message;
}
add('state dir writable', stateOk, stateDetail);

let snapOk = true;
let snapDetail = '';
try {
Expand Down
26 changes: 18 additions & 8 deletions src/mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -1276,8 +1276,8 @@ function createServer(options) {
// let a write that landed in between make the revision describe a
// record these layers never saw — a number that certifies the wrong
// bytes is worse than no number.
const snapshot = state.loadState(record.root);
const ledger = state.loadLedger(record.root);
const snapshot = state.peekState(record.root);
const ledger = state.peekLedger(record.root);
// readEventsWithHealth, not readEvents: the malformed-line warning goes
// to stderr, and no MCP client reads stderr. Damage that only appears
// there is damage the wire silently certifies as clean.
Expand Down Expand Up @@ -1581,12 +1581,22 @@ function createServer(options) {
const available = resourceRecord(params.uri);
// 4b: every resource states the WAL observation out loud. The flag is
// derived at read time and injected into the projection only — the disk
// bytes never gain it. Reads stay byte-pure: the sampler never repairs.
const sampled = samplePendingIntent(available.record.root, () => {
if (available.parsed.name === 'state') return state.loadState(available.record.root);
if (available.parsed.name === 'ledger') return state.loadLedger(available.record.root);
return receipt.assemble(available.record.root);
});
// bytes never gain it. Reads stay byte-pure: peeks, not the resilient
// loaders — a resource read must never create a record or back one up.
let sampled;
try {
sampled = samplePendingIntent(available.record.root, () => {
if (available.parsed.name === 'state') return state.peekState(available.record.root);
if (available.parsed.name === 'ledger') return state.peekLedger(available.record.root);
return receipt.assemble(available.record.root, { peek: true });
});
} catch (e) {
if (e && e.code === 'ERATCHETMIRROR') {
// The conservative refusal: the one allowlisted sentence, no store path.
throw rpc.rpcError(-32603, WRITE_REFUSALS.MirrorUnrecoverable);
}
throw e;
}
const value = Object.assign({}, sampled.value, { pendingIntent: sampled.pendingIntent });

return withCache({
Expand Down
18 changes: 12 additions & 6 deletions src/receipt.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,19 @@ function authorityState(git) {
return { level: 'committed-local', label: 'committed-local — HEAD is on no remote branch' };
}

function assemble(cwd = process.cwd()) {
const s = state.loadState(cwd);
function assemble(cwd = process.cwd(), opts = {}) {
// MCP resource reads peek (byte-pure, refuse on unprovable bytes); the CLI
// cold read keeps its resilient loaders — it may be the first touch ever.
const s = opts.peek ? state.peekState(cwd) : state.loadState(cwd);
let ledger = {};
try {
ledger = state.loadLedger(cwd);
} catch (_e) {
ledger = {};
if (opts.peek) {
ledger = state.peekLedger(cwd);
} else {
try {
ledger = state.loadLedger(cwd);
} catch (_e) {
ledger = {};
}
}

let events = [];
Expand Down
Loading
Loading