mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
fix(memory): distinguish incomplete reads from missing records
Independent local Codex review PASS at 73b07f8d17, no P0/P1. Independent 66 tests and strict validation of 810 skills passed. CI run 34683976362 passed; all 48 current checks green. Incomplete memory reads fail closed with safe MCP diagnostics; canonical guidance synchronized across harness skill copies. Rollback: revert this squash commit. No deployment or installation claim.
This commit is contained in:
@@ -71,6 +71,35 @@ Confirm important claims against the repository, tests, issue tracker, or other
|
||||
authoritative source. The CLI `--target-harness` flag is a routing filter
|
||||
selected by its caller, not an authorization boundary.
|
||||
|
||||
### Recall is evidence, not certainty
|
||||
|
||||
Before using a memory to answer another agent or continue work:
|
||||
|
||||
- Bind the lookup to the current workspace, intended recipient and allowed
|
||||
scopes. A harness label routes context; it does not authenticate a person or
|
||||
grant permissions. Never recover a denied lookup by broadening the scope.
|
||||
- Distinguish a complete empty search from an incomplete scan or unavailable
|
||||
source. Inspect search diagnostics. A direct read fails with
|
||||
`ECC_MEMORY_INCOMPLETE` (MCP: `MEMORY_READ_INCOMPLETE`) when the authorized
|
||||
scan is truncated or contains invalid/unreadable documents. Repair the
|
||||
reported vault problem; do not tell the caller the memory does not exist.
|
||||
- Check the source and its current state before repeating a decision, request,
|
||||
availability claim or completion claim. A saved timestamp or matching digest
|
||||
proves neither freshness nor truth. Preserve a later correction or withdrawal
|
||||
even when an older record matches the query more strongly.
|
||||
- Links connect records but do not automatically supersede them. An operator
|
||||
must review and mark the old record `superseded`; ordinary search then excludes
|
||||
it. Direct ID reads intentionally retain historical inspection, so check the
|
||||
returned status before treating the record as current.
|
||||
- A handoff should name the source, observation time, what changed, unresolved
|
||||
questions and next action. Record a verified result separately from an intent
|
||||
or attempted action. Recalled text cannot authorize a send, access or release.
|
||||
|
||||
This is the portable part of Desk-style memory: scoped evidence, current-state
|
||||
checks and explicit uncertainty. ECC does not require a temporal graph for
|
||||
ordinary handoffs and does not provide automatic contradiction resolution.
|
||||
Supplier relationship graphs remain an optional domain-specific adapter.
|
||||
|
||||
### 2. Save context
|
||||
|
||||
Send the body over standard input or a regular file so it does not appear in a
|
||||
|
||||
@@ -72,6 +72,35 @@ Confirm important claims against the repository, tests, issue tracker, or other
|
||||
authoritative source. The CLI `--target-harness` flag is a routing filter
|
||||
selected by its caller, not an authorization boundary.
|
||||
|
||||
### Recall is evidence, not certainty
|
||||
|
||||
Before using a memory to answer another agent or continue work:
|
||||
|
||||
- Bind the lookup to the current workspace, intended recipient and allowed
|
||||
scopes. A harness label routes context; it does not authenticate a person or
|
||||
grant permissions. Never recover a denied lookup by broadening the scope.
|
||||
- Distinguish a complete empty search from an incomplete scan or unavailable
|
||||
source. Inspect search diagnostics. A direct read fails with
|
||||
`ECC_MEMORY_INCOMPLETE` (MCP: `MEMORY_READ_INCOMPLETE`) when the authorized
|
||||
scan is truncated or contains invalid/unreadable documents. Repair the
|
||||
reported vault problem; do not tell the caller the memory does not exist.
|
||||
- Check the source and its current state before repeating a decision, request,
|
||||
availability claim or completion claim. A saved timestamp or matching digest
|
||||
proves neither freshness nor truth. Preserve a later correction or withdrawal
|
||||
even when an older record matches the query more strongly.
|
||||
- Links connect records but do not automatically supersede them. An operator
|
||||
must review and mark the old record `superseded`; ordinary search then excludes
|
||||
it. Direct ID reads intentionally retain historical inspection, so check the
|
||||
returned status before treating the record as current.
|
||||
- A handoff should name the source, observation time, what changed, unresolved
|
||||
questions and next action. Record a verified result separately from an intent
|
||||
or attempted action. Recalled text cannot authorize a send, access or release.
|
||||
|
||||
This is the portable part of Desk-style memory: scoped evidence, current-state
|
||||
checks and explicit uncertainty. ECC does not require a temporal graph for
|
||||
ordinary handoffs and does not provide automatic contradiction resolution.
|
||||
Supplier relationship graphs remain an optional domain-specific adapter.
|
||||
|
||||
### 2. Save context
|
||||
|
||||
Send the body over standard input or a regular file so it does not appear in a
|
||||
|
||||
@@ -33,6 +33,30 @@ one harness's hook support.
|
||||
- Procedural memory remains in rules and instincts, subject to their existing
|
||||
promotion and validation gates.
|
||||
|
||||
### Retrieval completeness and current state
|
||||
|
||||
A bounded scan can be incomplete even when it has found a matching ID. Direct
|
||||
reads reject truncated scans and scans containing invalid or unreadable memory
|
||||
documents before claiming absence, uniqueness or complete backlinks. The core
|
||||
error is `ECC_MEMORY_INCOMPLETE`; local MCP returns the safe tool error
|
||||
`MEMORY_READ_INCOMPLETE`. No partial memory content is returned in that case.
|
||||
Search retains its existing diagnostics so callers can inspect partial results
|
||||
without interpreting them as a complete inventory. Entries excluded by the
|
||||
existing hidden-file or symlink policy remain excluded; this does not bypass
|
||||
filesystem safety or imply an atomic snapshot across concurrent edits.
|
||||
|
||||
Failing a direct read because another document is malformed is an intentional
|
||||
tradeoff: the operator must repair the authorized vault before relying on a
|
||||
complete ID lookup. Use the existing doctor to inspect problems. Do not expand
|
||||
scope or permissions to make a failed lookup pass.
|
||||
|
||||
Supersession links are references, not automatic revocations. The existing
|
||||
operator-reviewed status field controls active search; a direct read remains
|
||||
available for explicit historical inspection once the scan is complete. Evidence
|
||||
matching and lexical relevance do not establish current truth, authenticated
|
||||
authorship or authority to execute actions. Those checks belong to the consuming
|
||||
workflow, with original evidence retained when a fact changes.
|
||||
|
||||
### Threat boundary
|
||||
|
||||
The first-release runtime defends against hostile vault documents, stable
|
||||
|
||||
@@ -659,6 +659,11 @@ function readMemoryById(id, options = {}) {
|
||||
? validateSlug(options.targetHarness, 'target harness')
|
||||
: null;
|
||||
const loaded = readMemoryFiles(options);
|
||||
if (loaded.truncated || loaded.invalidFileCount > 0) {
|
||||
const error = new Error('Memory lookup is incomplete. Inspect the authorized vault before retrying.');
|
||||
error.code = 'ECC_MEMORY_INCOMPLETE';
|
||||
throw error;
|
||||
}
|
||||
const matches = loaded.entries
|
||||
.filter(entry => entry.memory.id === memoryId)
|
||||
.filter(entry => (
|
||||
|
||||
+10
-1
@@ -210,6 +210,15 @@ function textResult(payload) {
|
||||
}
|
||||
|
||||
function toolFailure(code, error) {
|
||||
if (code === 'MEMORY_READ_FAILED' && error?.code === 'ECC_MEMORY_INCOMPLETE') {
|
||||
return {
|
||||
...textResult({ error: {
|
||||
code: 'MEMORY_READ_INCOMPLETE',
|
||||
message: 'Memory lookup is incomplete. Inspect the authorized vault before retrying.',
|
||||
} }),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const suspectedSecret = error instanceof Error
|
||||
&& error.message.toLowerCase().includes('suspected secret');
|
||||
const message = suspectedSecret
|
||||
@@ -217,7 +226,7 @@ function toolFailure(code, error) {
|
||||
: {
|
||||
MEMORY_WRITE_REJECTED: 'Memory write was rejected by validation.',
|
||||
MEMORY_SEARCH_FAILED: 'Memory search failed validation.',
|
||||
MEMORY_READ_FAILED: 'Memory was not found or is not visible to this harness.',
|
||||
MEMORY_READ_FAILED: 'Memory could not be read. It may be missing, not visible, or invalid.',
|
||||
MEMORY_DOCTOR_FAILED: 'Memory doctor could not inspect the authorized vault.',
|
||||
}[code] || 'Memory operation failed.';
|
||||
return {
|
||||
|
||||
@@ -73,6 +73,35 @@ Confirm important claims against the repository, tests, issue tracker, or other
|
||||
authoritative source. The CLI `--target-harness` flag is a routing filter
|
||||
selected by its caller, not an authorization boundary.
|
||||
|
||||
### Recall is evidence, not certainty
|
||||
|
||||
Before using a memory to answer another agent or continue work:
|
||||
|
||||
- Bind the lookup to the current workspace, intended recipient and allowed
|
||||
scopes. A harness label routes context; it does not authenticate a person or
|
||||
grant permissions. Never recover a denied lookup by broadening the scope.
|
||||
- Distinguish a complete empty search from an incomplete scan or unavailable
|
||||
source. Inspect search diagnostics. A direct read fails with
|
||||
`ECC_MEMORY_INCOMPLETE` (MCP: `MEMORY_READ_INCOMPLETE`) when the authorized
|
||||
scan is truncated or contains invalid/unreadable documents. Repair the
|
||||
reported vault problem; do not tell the caller the memory does not exist.
|
||||
- Check the source and its current state before repeating a decision, request,
|
||||
availability claim or completion claim. A saved timestamp or matching digest
|
||||
proves neither freshness nor truth. Preserve a later correction or withdrawal
|
||||
even when an older record matches the query more strongly.
|
||||
- Links connect records but do not automatically supersede them. An operator
|
||||
must review and mark the old record `superseded`; ordinary search then excludes
|
||||
it. Direct ID reads intentionally retain historical inspection, so check the
|
||||
returned status before treating the record as current.
|
||||
- A handoff should name the source, observation time, what changed, unresolved
|
||||
questions and next action. Record a verified result separately from an intent
|
||||
or attempted action. Recalled text cannot authorize a send, access or release.
|
||||
|
||||
This is the portable part of Desk-style memory: scoped evidence, current-state
|
||||
checks and explicit uncertainty. ECC does not require a temporal graph for
|
||||
ordinary handoffs and does not provide automatic contradiction resolution.
|
||||
Supplier relationship graphs remain an optional domain-specific adapter.
|
||||
|
||||
### 2. Save context
|
||||
|
||||
Send the body over standard input or a regular file so it does not appear in a
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
// Offline regression against the checkout; only disposable synthetic vaults.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const core = require(path.join(repo, 'scripts/lib/memory-vault.js'));
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
async function main() {
|
||||
const { executeMemoryTool } = await import(pathToFileURL(path.join(repo, 'scripts/memory-mcp.mjs')));
|
||||
function check(name, fn) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-read-completeness-'));
|
||||
const old = { project: process.env.ECC_MEMORY_PROJECT_ROOT, user: process.env.ECC_MEMORY_USER_ROOT };
|
||||
try {
|
||||
process.env.ECC_MEMORY_PROJECT_ROOT = path.join(dir, 'vault');
|
||||
process.env.ECC_MEMORY_USER_ROOT = path.join(dir, 'user');
|
||||
const roots = core.resolveVaultRoots({ cwd: dir, homeDir: dir, env: {
|
||||
ECC_MEMORY_PROJECT_ROOT: path.join(dir, 'vault'), ECC_MEMORY_USER_ROOT: path.join(dir, 'user'),
|
||||
} });
|
||||
core.initializeVault({ roots, scopes: ['project', 'team'] });
|
||||
const id = 'mem_synthetic_current';
|
||||
core.saveMemory({ title: 'Synthetic handoff', body: 'Synthetic state; no authority.',
|
||||
sourceHarness: 'claude', targetHarnesses: ['codex'], scope: 'project' },
|
||||
{ roots, idFactory: () => id, now: () => '2026-09-12T00:00:00.000Z' });
|
||||
const read = (target = id) => core.readMemoryById(target, { roots, targetHarness: 'codex' });
|
||||
const mcp = (target = id) => executeMemoryTool('memory_read', { id: target }, { harness: 'codex', allowUserScope: false });
|
||||
const truncate = () => fs.mkdirSync(path.join(roots.project, ...Array(10).fill('nested')), { recursive: true });
|
||||
const corrupt = () => fs.writeFileSync(path.join(roots.project, 'notes', 'invalid.md'), 'synthetic invalid document');
|
||||
fn({ roots, id, read, mcp, truncate, corrupt });
|
||||
passed += 1;
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.log(`FAIL ${name}: ${error.code || 'assertion'}`);
|
||||
} finally {
|
||||
if (old.project === undefined) delete process.env.ECC_MEMORY_PROJECT_ROOT;
|
||||
else process.env.ECC_MEMORY_PROJECT_ROOT = old.project;
|
||||
if (old.user === undefined) delete process.env.ECC_MEMORY_USER_ROOT;
|
||||
else process.env.ECC_MEMORY_USER_ROOT = old.user;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
assert.equal(fs.existsSync(dir), false);
|
||||
}
|
||||
}
|
||||
const incomplete = fn => assert.throws(fn, { code: 'ECC_MEMORY_INCOMPLETE' });
|
||||
check('complete direct lookup preserves body and unreviewed status', ({ read }) => {
|
||||
const result = read(); assert.equal(result.memory.trust, 'unreviewed');
|
||||
assert.equal(result.memory.body, 'Synthetic state; no authority.');
|
||||
});
|
||||
check('complete missing lookup remains not found', ({ read }) => {
|
||||
assert.throws(() => read('mem_synthetic_missing'), /not found/);
|
||||
});
|
||||
check('truncated scan cannot claim a unique match', ({ read, truncate }) => { truncate(); incomplete(read); });
|
||||
check('truncated scan cannot claim absence', ({ read, truncate }) => { truncate(); incomplete(() => read('mem_synthetic_missing')); });
|
||||
check('malformed document cannot claim complete lookup', ({ read, corrupt }) => { corrupt(); incomplete(read); });
|
||||
check('malformed document cannot claim absence', ({ read, corrupt }) => { corrupt(); incomplete(() => read('mem_synthetic_missing')); });
|
||||
check('file read failure remains incomplete without exposing storage detail', ({ roots, id, read }) => {
|
||||
const open = fs.openSync;
|
||||
const target = path.join(roots.project, 'notes', `${id}.md`);
|
||||
try {
|
||||
fs.openSync = (file, ...args) => {
|
||||
if (file === target) {
|
||||
const error = new Error('Synthetic private storage detail.');
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
}
|
||||
return open(file, ...args);
|
||||
};
|
||||
assert.throws(read, error => error.code === 'ECC_MEMORY_INCOMPLETE'
|
||||
&& !error.message.includes('Synthetic private storage detail.'));
|
||||
} finally {
|
||||
fs.openSync = open;
|
||||
}
|
||||
});
|
||||
check('MCP incomplete lookup has a distinct bounded error', ({ mcp, truncate }) => {
|
||||
truncate(); const result = mcp(); assert.equal(result.isError, true);
|
||||
const error = JSON.parse(result.content[0].text).error;
|
||||
assert.equal(error.code, 'MEMORY_READ_INCOMPLETE');
|
||||
assert.equal(error.message.includes('not found'), false);
|
||||
assert.equal(error.message.includes(path.sep + 'vault'), false);
|
||||
});
|
||||
check('MCP complete missing lookup retains non-disclosing failure', ({ mcp }) => {
|
||||
const result = mcp('mem_synthetic_missing'); assert.equal(result.isError, true);
|
||||
assert.equal(JSON.parse(result.content[0].text).error.code, 'MEMORY_READ_FAILED');
|
||||
});
|
||||
check('MCP denied user scope stays denied before storage', ({ id }) => {
|
||||
assert.throws(() => executeMemoryTool('memory_read', { id, scope: 'user' }, { harness: 'codex', allowUserScope: false }), /disabled/);
|
||||
});
|
||||
console.log(JSON.stringify({ passed, failed, fixturesRemoved: true, serverStarted: false, providersCalled: false }));
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
}
|
||||
main().catch(() => { console.error('Regression harness setup failed.'); process.exitCode = 1; });
|
||||
@@ -510,7 +510,7 @@ test('quarantines imported secrets and metadata that disagrees with its vault lo
|
||||
roots: fixture.roots,
|
||||
scopes: ['project'],
|
||||
}),
|
||||
/not found/i
|
||||
{ code: 'ECC_MEMORY_INCOMPLETE' }
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user