diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 9129759..9600a41 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,8 +1,8 @@ @@ -3010,6 +3010,8 @@ parameter's description in the tool listing now says so. That changes the `query_receipts` tool definition, so a client that fingerprints tool definitions will see it as changed; that is the intended reading. +**Amended 2026-11-05, branch `lotor-lane/since-attribute`: the since-view half is closed.** `src/views/since.js` now lists a `gated-action` or `policy-warn` row under the session its own `sessionId` names, and only then. Key absent, `null`, empty or non-string stays in `unattributed`, and `egress-event` always does; nothing is attributed by timestamp. The id is the hook's self-report (limit 1). A session showing no denials may still own an unattributed row, so the view prints no zero count and its caveat says so. Tested on synthetic rows only; not run against the live chain. The `sessions_since` tool description changed, so a client that fingerprints tool definitions will see it as changed (limit 41). The paragraph below is history as to `since.js`; its `docs/receipt-coverage.md` half is a separate change. + **Open, same class, not fixed here.** `src/views/since.js`, behind `sessions_since`, still routes every `gated-action`, `policy-warn` and `egress-event` row into its `unattributed` lists and prints the caveat "Gate diff --git a/src/mcp/server.js b/src/mcp/server.js index e8aef0e..b420eaa 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -465,7 +465,7 @@ function createMcpServer() { }, { name: 'sessions_since', - description: 'What happened while you were not looking. Summarises every session recorded in the chain since a point in time, grouped by session, with what each one ran and which files it touched. Use this BEFORE asserting that something does not exist, has not been built, or was never done: concurrent sessions cannot see each other, and the chain is the only place their work is visible. Also worth calling at the start of a session to find out what changed since the last one. Sessions that opened and did nothing are counted rather than listed. Gate decisions carry no session id and are reported unattributed rather than guessed at. Reports what it cannot tell you: receipts carry which tools ran and a digest of their parameters, never intent, and capture is self-attested, so an empty result means nothing was recorded rather than nothing happened.', + description: 'What happened while you were not looking. Summarises every session recorded in the chain since a point in time, grouped by session, with what each one ran and which files it touched. Use this BEFORE asserting that something does not exist, has not been built, or was never done: concurrent sessions cannot see each other, and the chain is the only place their work is visible. Also worth calling at the start of a session to find out what changed since the last one. Sessions that opened and did nothing are counted rather than listed. Gate decisions whose row states a session id are listed under that session; rows with no session id, including all written before 2026-09-07, are reported unattributed rather than guessed at. Reports what it cannot tell you: receipts carry which tools ran and a digest of their parameters, never intent, and capture is self-attested, so an empty result means nothing was recorded rather than nothing happened.', inputSchema: { type: 'object', properties: { diff --git a/src/views/since.js b/src/views/since.js index 4848e02..179c73b 100644 --- a/src/views/since.js +++ b/src/views/since.js @@ -21,10 +21,10 @@ * 2. Capture is self-attested. A clean since-view is not proof another * session did nothing; it is proof nothing was recorded. Silence is not * safety. - * 3. `gated-action` payloads carry NO session id. Denials therefore cannot be - * attributed to a session, and with concurrent sessions running they must - * not be guessed at from timestamps. They are reported as an unattributed - * timeline, deliberately separate from the per-session rollup. + * 3. Gate rows written since 2026-09-07 carry the session id the hook passed, + * self-reported (limit 1), and are listed under that session; older rows, + * rows with a null id, and egress rows are an unattributed timeline and + * are never guessed at from timestamps. */ import { loadChain } from '../store/index.js'; @@ -40,6 +40,13 @@ export function typeOf(payload) { return payload?.type ?? payload?.kind ?? (payload?.session ? 'session' : 'unknown'); } +/** + * The owner of a gate row, read the way limit 74 draws it: a row is attributed + * only when its own payload states a non-empty string `sessionId`. Key absent, + * `null`, empty, or any non-string means no owner, and no other spelling counts. + */ +const ownerOf = p => (typeof p.sessionId === 'string' && p.sessionId !== '') ? p.sessionId : null; + /** * Resolve a `since` argument into a millisecond timestamp. * Accepts: ms number, ISO string, or `{ seq }` to start after a chain sequence. @@ -92,6 +99,14 @@ export function sinceReport(entries, opts = {}) { // Paths whose receipt item carried outcome 'error', kept beside the // bare path list so the render can mark the refusal in place. refusedPaths: [], + // Gate rows the row's own `sessionId` attributes to this session + // (written since 2026-09-07, limit 35; the split is limit 74). + // firstGateAt is the timestamp of its first attributed gate row, null + // when none: the sort key for a session with no open and no close. + denials: [], + approvals: [], + warns: [], + firstGateAt: null, receiptSeq: null }); } @@ -163,20 +178,53 @@ export function sinceReport(entries, opts = {}) { break; } - // No session id on this payload. Attributing it by timestamp would be a - // guess, and with concurrent sessions a guess is wrong often enough to be - // worse than an honest gap. - case 'gated-action': + // A gate row written since 2026-09-07 carries the session id the hook + // passed (self-reported, limit 1), and is listed under that session. + // A row with no id — all rows older than that, and some since — stays + // in the unattributed timeline. Attributing by timestamp would be a + // guess, and with concurrent sessions a guess is wrong often enough to + // be worse than an honest gap. Only camelCase `sessionId` counts: a + // snake_case `session_id` is a different spelling, not an owner. + case 'gated-action': { + const ownerId = ownerOf(p); + if (ownerId) { + const s = ensure(ownerId); + if (s.firstGateAt === null) s.firstGateAt = e.timestamp; + if ((p.decision ?? null) === 'denied') { + s.denials.push({ + seq: e.seq, timestamp: e.timestamp, + action: p.action ?? null, reason: p.reason ?? null + }); + } else { + s.approvals.push({ + seq: e.seq, timestamp: e.timestamp, + decision: p.decision ?? null, action: p.action ?? null + }); + } + break; + } denials.push({ seq: e.seq, timestamp: e.timestamp, decision: p.decision ?? null, action: p.action ?? null, reason: p.reason ?? null }); break; + } - case 'policy-warn': + case 'policy-warn': { + // Same split as `gated-action`, on the payload's own id. + const ownerId = ownerOf(p); + if (ownerId) { + const s = ensure(ownerId); + if (s.firstGateAt === null) s.firstGateAt = e.timestamp; + s.warns.push({ seq: e.seq, timestamp: e.timestamp, ruleId: p.ruleId ?? null }); + break; + } warns.push({ seq: e.seq, timestamp: e.timestamp, ruleId: p.ruleId ?? null }); break; + } + // Egress rows carry no session id in either spelling. One present on + // the payload is not read as an owner: this row type is never attributed. case 'egress-event': egress.push({ seq: e.seq, timestamp: e.timestamp, ruleId: p.ruleId ?? null }); break; @@ -190,7 +238,8 @@ export function sinceReport(entries, opts = {}) { if (opts.excludeSessionId) { list = list.filter(s => s.sessionId !== opts.excludeSessionId); } - list.sort((a, b) => (a.opened ?? a.closed ?? 0) - (b.opened ?? b.closed ?? 0)); + list.sort((a, b) => (a.opened ?? a.closed ?? a.firstGateAt ?? 0) - + (b.opened ?? b.closed ?? b.firstGateAt ?? 0)); // Most sessions open and do nothing. A single day produced 107 of them, and // listing all 107 in full buries the two or three that actually built @@ -201,7 +250,11 @@ export function sinceReport(entries, opts = {}) { // session can close having done nothing, and a session can do work and die // before writing its receipt. The second kind must stay visible, because an // unclosed session that ran tools is the case KNOWN-LIMITS 14 is about. - const didWork = s => (s.toolCalls ?? 0) > 0 || (s.touchedCount ?? 0) > 0; + // A session whose only record is a gate row — a denial with no receipt + // written, no open row seen — is evidence of work the same way an unclosed + // session that ran tools is, and stays listed for the same reason. + const didWork = s => (s.toolCalls ?? 0) > 0 || (s.touchedCount ?? 0) > 0 + || s.denials.length > 0 || s.approvals.length > 0 || s.warns.length > 0; const active = list.filter(didWork); const quiet = list.filter(s => !didWork(s)); @@ -224,7 +277,7 @@ export function sinceReport(entries, opts = {}) { caveats: [ 'Receipts carry which named tools ran and a digest of their parameters. They never carry intent.', 'Capture is self-attested. An empty report means nothing was recorded, not that nothing happened.', - 'Gate decisions carry no session id and are listed unattributed rather than guessed at by time.', + 'A gate decision is listed under a session only when its row states that session\'s id, and that id is self-reported by the hook. Rows with no session id (all written before 2026-09-07, and some since) are listed unattributed rather than guessed at by time, so a session showing no denials may still own one of those.', 'A touched path is a write the transcript recorded an attempt at; a path marked refused means the harness returned an error for every attempt.' ] }; @@ -238,6 +291,20 @@ export function renderSince(report) { const L = []; const w = report.window; + // One tally, two readers: the per-session gate line and the unattributed + // block both count rows by the action the gate was asked for. + const tallyByAction = rows => { + // Counted on a Map: a plain object inherits `constructor` from the + // prototype and swallows `__proto__` into its setter, so action names that + // collide with Object members misreported or vanished (WO-SINCE-ATTRIBUTE-2). + const byAction = new Map(); + for (const d of rows) { + const a = d.action ?? '?'; + byAction.set(a, (byAction.get(a) ?? 0) + 1); + } + return [...byAction.entries()].map(([a, n]) => `${a} x${n}`); + }; + L.push(''); L.push('WHAT HAPPENED WHILE YOU WERE NOT LOOKING'); L.push('='.repeat(66)); @@ -271,6 +338,17 @@ export function renderSince(report) { : `${s.touchedCount} files touched`); } L.push(` work ${bits.length ? bits.join(', ') : 'no close receipt, so no counts'}`); + // Gate rows the session's own id claimed. Only non-zero parts print, so + // a session with no attributed row gets no line at all — it may still + // own a row in the unattributed list, and a "0 denied" would read as a + // denial count when the report itself refuses to make one. + const gateParts = []; + if (s.denials?.length) { + gateParts.push(`${s.denials.length} denied (${tallyByAction(s.denials).join(', ')})`); + } + if (s.approvals?.length) gateParts.push(`${s.approvals.length} approved`); + if (s.warns?.length) gateParts.push(`${s.warns.length} warnings`); + if (gateParts.length) L.push(` gate ${gateParts.join(', ')}`); if (s.touched && s.touched.length) { // Mark the refusal on the path itself (limit 76): a reader scanning // the paths must not have to cross-check the count line. @@ -287,13 +365,10 @@ export function renderSince(report) { } const u = report.unattributed; - if (u.denials.length || u.warns.length || u.egress.length) { + if (u.denials.length || u.approvals.length || u.warns.length || u.egress.length) { L.push(' UNATTRIBUTED — these carry no session id and are not guessed at'); if (u.denials.length) { - const byAction = {}; - for (const d of u.denials) byAction[d.action ?? '?'] = (byAction[d.action ?? '?'] ?? 0) + 1; - const parts = Object.entries(byAction).map(([a, n]) => `${a} x${n}`); - L.push(` denied ${u.denials.length} (${parts.join(', ')})`); + L.push(` denied ${u.denials.length} (${tallyByAction(u.denials).join(', ')})`); } if (u.approvals.length) L.push(` approved ${u.approvals.length}`); if (u.warns.length) L.push(` warnings ${u.warns.length}`); diff --git a/test/since-view.test.js b/test/since-view.test.js index dab7980..24cee2d 100644 --- a/test/since-view.test.js +++ b/test/since-view.test.js @@ -64,15 +64,16 @@ test('sessions that opened and did nothing are counted, not enumerated', () => { assert.equal(withQuiet.sessions.length, 2, 'they are still reachable on request'); }); -test('gate decisions are unattributed rather than guessed at', () => { - // gated-action payloads carry no session id. With concurrent sessions, - // attributing by timestamp would be wrong often enough to be worse than a - // stated gap. +test('a gate denial with no session id stays unattributed rather than guessed at', () => { + // Rows written before 2026-09-07 (and some since) carry no sessionId. With + // concurrent sessions, attributing by timestamp would be wrong often enough + // to be worse than a stated gap, so the row stays a stated gap. const r = sinceReport(fixture()); assert.equal(r.unattributed.denials.length, 1); assert.equal(r.unattributed.denials[0].action, 'Bash'); for (const s of r.sessions) { - assert.ok(!('denials' in s), 'a denial must never be pinned to a session'); + assert.equal(s.denials.length, 0, + 'a denial whose row states no session id must never be pinned to a session'); } }); @@ -113,3 +114,179 @@ test('subsession receipts accumulate rather than overwrite', () => { assert.equal(r.sessions[0].toolCalls, 14); assert.deepEqual(r.sessions[0].touched, ['/a', '/b']); }); + +// --- Gate rows that carry a session id (limit 74, since-view half) --- +// +// Lotor records and never infers: a gate row is attributed only when its own +// payload states a non-empty string `sessionId`, and never by timestamp. All +// rows here are synthetic. + +test('a gate row whose sessionId states A lands on A, and on nothing else', () => { + const entries = [ + at(1000, { type: 'gated-action', decision: 'denied', action: 'Bash', reason: 'no token', sessionId: 'A' }, 1), + at(1100, { type: 'gated-action', decision: 'allowed', action: 'Edit', sessionId: 'B' }, 2) + ]; + const r = sinceReport(entries); + + const a = r.sessions.find(s => s.sessionId === 'A'); + assert.ok(a, 'a denied gate row is evidence of work (a)'); + assert.equal(a.denials.length, 1, 'the denial goes on A\'s record'); + assert.equal(a.denials[0].action, 'Bash'); + assert.equal(a.denials[0].reason, 'no token'); + assert.equal(a.denials[0].seq, 1); + assert.equal(a.approvals.length, 0); + assert.deepEqual(a.firstGateAt, 1000, 'firstGateAt is its first attributed gate row (b)'); + + const b = r.sessions.find(s => s.sessionId === 'B'); + assert.ok(b); + assert.equal(b.approvals.length, 1, 'the approval goes on B\'s record'); + assert.equal(b.approvals[0].decision, 'allowed'); + assert.equal(b.approvals[0].action, 'Edit'); + assert.equal(b.denials.length, 0); + + assert.equal(r.unattributed.denials.length, 0, 'attributed rows leave the unattributed lists'); + assert.equal(r.unattributed.approvals.length, 0); +}); + +test('a gate row that states no usable sessionId stays unattributed', () => { + // Absent, null, empty, non-string, and snake_case are all no-owner states. + // Only camelCase `sessionId` with a non-empty string attributes. + const entries = [ + at(1000, { type: 'gated-action', decision: 'denied', action: 'Bash' }, 1), + at(1100, { type: 'gated-action', decision: 'denied', action: 'Bash', sessionId: null }, 2), + at(1200, { type: 'gated-action', decision: 'denied', action: 'Bash', sessionId: '' }, 3), + at(1300, { type: 'gated-action', decision: 'denied', action: 'Bash', sessionId: 7 }, 4), + at(1400, { type: 'gated-action', decision: 'denied', action: 'Bash', session_id: 'A' }, 5) + ]; + const r = sinceReport(entries); + assert.equal(r.unattributed.denials.length, 5, 'all five stay in unattributed'); + assert.equal(r.sessions.length, 0, 'no session record was created for any of them'); + assert.deepEqual(r.quietSessionIds, []); +}); + +test('a policy-warn with a session id lands on warns; without one stays unattributed', () => { + const entries = [ + at(1000, { type: 'policy-warn', ruleId: 'r1', sessionId: 'A' }, 1), + at(1100, { type: 'policy-warn', ruleId: 'r2' }, 2) + ]; + const r = sinceReport(entries); + + const a = r.sessions.find(s => s.sessionId === 'A'); + assert.ok(a); + assert.equal(a.warns.length, 1); + assert.equal(a.warns[0].ruleId, 'r1'); + assert.equal(a.firstGateAt, 1000); + + assert.equal(r.unattributed.warns.length, 1); + assert.equal(r.unattributed.warns[0].ruleId, 'r2'); +}); + +test('an egress row never attributes, even when a sessionId key is on it', () => { + const entries = [ + at(1000, { type: 'egress-event', ruleId: 'eg1', sessionId: 'A' }, 1) + ]; + const r = sinceReport(entries); + assert.equal(r.unattributed.egress.length, 1, 'the row stays in unattributed.egress'); + assert.equal(r.sessions.length, 0, 'no session record was created for the id on it'); +}); + +test('a session whose only row is a denial is listed, not counted quiet', () => { + // No open, no close receipt: the denial is itself evidence of work, so the + // session must not read as "opened idle, not listed" (a). + const entries = [ + at(1000, { type: 'gated-action', decision: 'denied', action: 'Bash', reason: 'no token', sessionId: 'A' }, 1) + ]; + const r = sinceReport(entries); + assert.equal(r.sessions.length, 1); + assert.equal(r.sessions[0].sessionId, 'A'); + assert.deepEqual(r.quietSessionIds, []); + assert.equal(r.sessions[0].opened, null); + assert.equal(r.sessions[0].firstGateAt, 1000); +}); + +test('a gate-only session sorts by its first gate row', () => { + // (b) A session with no open and no close must not sort as 0. Sort on the + // first attributed gate row when that is all there is. + const entries = [ + at(1000, { type: 'session-open', sessionId: 'OPEN1', source: 'startup' }, 1), + at(2000, { type: 'gated-action', decision: 'denied', action: 'Bash', sessionId: 'GATE' }, 2), + at(3000, { type: 'session-open', sessionId: 'OPEN3', source: 'startup' }, 3) + ]; + const r = sinceReport(entries, { includeQuiet: true }); + assert.deepEqual(r.sessions.map(s => s.sessionId), ['OPEN1', 'GATE', 'OPEN3'], + 'the gate-only session sits between the sessions opened around it'); +}); + +test('an approvals-only window still prints the unattributed block', () => { + // (c) The block guard counts denials, warnings and egress; without approvals + // in it, an approvals-only window printed nothing. + const entries = [ + at(1000, { type: 'gated-action', decision: 'allowed', action: 'Bash' }, 1) + ]; + const out = renderSince(sinceReport(entries, {})); + assert.match(out, /approved/, 'the unattributed block prints, with its approval line'); +}); + +test('a working session with no attributed gate rows gets no gate line and no zero count', () => { + // (d) A session with no attributed row may still own a row in the + // unattributed list, so printing "0 denied" would state a count the report + // refuses to make. + const out = renderSince(sinceReport(fixture(), {})); + assert.ok(!/^ gate /m.test(out), 'no gate line prints'); + assert.ok(!out.includes('0 denied'), 'no zero count prints for the gate line'); +}); + +test('excludeSessionId drops the attributed rows entirely, not into unattributed', () => { + // (e) Filtering happens after attribution, so excluded rows are simply gone. + const entries = [ + at(1000, { type: 'gated-action', decision: 'denied', action: 'Bash', sessionId: 'A' }, 1) + ]; + const r = sinceReport(entries, { excludeSessionId: 'A' }); + assert.equal(r.sessions.length, 0); + assert.equal(r.unattributed.denials.length, 0, + 'the exclusion must not relocate the denial into unattributed'); +}); + +test('the per-session gate line reads as denied (tally), approved, warnings', () => { + // Parts join in that order, only non-zero parts print, and the denied tally + // is by action. + const entries = [ + at(1000, { type: 'gated-action', decision: 'denied', action: 'Bash', sessionId: 'A' }, 1), + at(1100, { type: 'gated-action', decision: 'denied', action: 'Bash', sessionId: 'A' }, 2), + at(1200, { type: 'gated-action', decision: 'allowed', action: 'Edit', sessionId: 'A' }, 3), + at(1300, { type: 'policy-warn', ruleId: 'r1', sessionId: 'A' }, 4), + at(1400, { type: 'policy-warn', ruleId: 'r2', sessionId: 'A' }, 5), + at(1500, { type: 'policy-warn', ruleId: 'r3', sessionId: 'A' }, 6) + ]; + const out = renderSince(sinceReport(entries, {})); + assert.match(out, /gate 2 denied \(Bash x2\), 1 approved, 3 warnings/); +}); + +test('the caveat no longer says gate decisions carry no session id', () => { + // The old sentence is false for rows written since 2026-09-07. + const out = renderSince(sinceReport(fixture(), {})); + assert.ok(!out.includes('Gate decisions carry no session id')); + assert.match(out, /no session id/, 'the phrase survives in the caveat'); +}); + +test('the action tally counts names that collide with Object members', () => { + // tallyByAction counted on a plain {}: `constructor` came back as the + // inherited Object constructor and `__proto__` was swallowed by setter + // semantics, so the tally misreported exactly these names. It counts on a + // Map now, so any action name is just a key. All rows here are synthetic. + for (const name of ['constructor', '__proto__', 'toString']) { + const entries = [ + at(1000, { type: 'gated-action', decision: 'denied', action: name, sessionId: 'A' }, 1), + at(1100, { type: 'gated-action', decision: 'denied', action: name, sessionId: 'A' }, 2) + ]; + const out = renderSince(sinceReport(entries, {})); + assert.ok(out.includes(`2 denied (${name} x2)`), `the tally handles ${name}`); + } + + const unattributed = [ + at(1000, { type: 'gated-action', decision: 'denied', action: 'constructor' }, 1) + ]; + const out = renderSince(sinceReport(unattributed, {})); + assert.ok(out.includes('denied 1 (constructor x1)'), + 'the unattributed tally counts the name too'); +});