Skip to content

Commit 9032bcd

Browse files
authored
Merge pull request #154 from clay-good/harden-memory-integrity-invariant
feat(memory): harden the memory-integrity invariant under concurrency + adversarial edits
2 parents 3af3046 + a7e5047 commit 9032bcd

21 files changed

Lines changed: 1811 additions & 171 deletions

openspec/specs/api/spec.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2977,3 +2977,17 @@ The API SHALL support `CLI Command openlore drift` to [partial spec — file too
29772977
- **GIVEN** A git repository with a pre-commit hook installed
29782978
- **WHEN** openlore drift --uninstall-hook is executed
29792979
- **THEN** The pre-commit hook is removed
2980+
2981+
### Requirement: DecisionApiWritesUseAtomicCompareAndSwap
2982+
2983+
The decision-writing API functions (`openloreRecordDecision`, `openloreConsolidateDecisions`,
2984+
`openloreSyncDecisions`) SHALL persist through the shared atomic compare-and-swap store path
2985+
(`updateDecisionStore`), so a write committed concurrently by another path (e.g. the MCP
2986+
`record_decision` handler or a background consolidation) is never silently clobbered.
2987+
`openloreRecordDecision` SHALL derive the decision id from the committed store's `sessionId`
2988+
so repeated records within a session deduplicate correctly.
2989+
2990+
#### Scenario: Concurrent API record and MCP record lose no decision
2991+
- **GIVEN** an `openloreRecordDecision` call and a concurrent `record_decision` against the same store
2992+
- **WHEN** both complete
2993+
- **THEN** the persisted store contains both decisions — neither write is lost

openspec/specs/architecture/spec.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,55 @@ The system SHALL implement security via: API key-based authentication for LLM pr
3838
- **WHEN** accessing protected resources
3939
- **THEN** access is denied
4040

41+
### Requirement: DurableAtomicStorePersistence
42+
43+
The persisted memory and decision stores (`.openlore/memory/notes.json`,
44+
`.openlore/decisions/pending.json`) SHALL be written atomically: a write goes to a
45+
uniquely-named temporary file, is `fsync`'d, and is moved into place with an atomic rename,
46+
after which the containing directory is `fsync`'d (best-effort) so the rename itself is
47+
durable. A crash or interruption mid-write leaves the previously committed store intact and
48+
never a partially written (torn) file. Each store SHALL carry a monotonic `sequence` field
49+
(defaults to `0` for legacy stores) that orders writes and lets external readers detect
50+
change. The read-modify-write SHALL be performed entirely inside a single per-store advisory
51+
lock so that the lock — not an optimistic sequence guard — is the serialization point:
52+
`mutate` always runs against the freshest on-disk store and a competing write cannot
53+
interleave. The advisory lock SHALL carry an ownership token and be released only by the
54+
writer that still owns it (so a hold stolen as stale is never freed out from under its new
55+
owner); a crashed holder's lock SHALL become stealable well before a waiter gives up, and a
56+
wait that times out SHALL fail loud rather than write unlocked. ALL writers of a given store
57+
(record, approve/reject, consolidation, sync, and HTTP-API equivalents) SHALL go through this
58+
single compare-and-swap path; a raw lock-free overwrite is prohibited because it would defeat
59+
the serialization. Implemented in `src/core/decisions/atomic-store.ts`; guarded by
60+
`atomic-store.test.ts`.
61+
62+
#### Scenario: A crash mid-write preserves the prior store
63+
- **GIVEN** a store write interrupted between writing the temporary file and the rename
64+
- **WHEN** the store is next loaded
65+
- **THEN** the previously committed store is returned intact, with no torn or partial content
66+
67+
#### Scenario: Save uses compare-and-swap on sequence
68+
- **GIVEN** a store loaded at sequence S
69+
- **WHEN** a save is attempted but the on-disk sequence is no longer S
70+
- **THEN** the save re-reads the current store, re-applies the pending change, and writes at
71+
the new sequence instead of overwriting
72+
73+
### Requirement: CorruptStoreQuarantineNotSilentEmpty
74+
75+
When a persisted store fails validation on load, the system SHALL move the unreadable file
76+
aside to a quarantine path (`*.corrupt-<n>`) and emit a recoverable signal. The system SHALL
77+
NOT silently substitute an empty store for a corrupt one, because silently losing persisted
78+
memory presents absence as current fact and violates the authoritative-recall invariant. The
79+
quarantine suffix SHALL be derived from on-disk state (the next free index), not wall-clock
80+
time, to keep recovery reproducible. The claim on a quarantine path SHALL be atomic (it fails
81+
if the path already exists), so two concurrent loaders can never overwrite each other's
82+
quarantine file and lose preserved bytes.
83+
84+
#### Scenario: A malformed store is quarantined, not silently emptied
85+
- **GIVEN** a store file that fails schema or JSON validation
86+
- **WHEN** the store is loaded
87+
- **THEN** the file is moved to `*.corrupt-<n>` and a recoverable signal is emitted, rather
88+
than an empty store being returned silently
89+
4190
## System Diagram
4291

4392
```mermaid

openspec/specs/cli/spec.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,49 @@ The system SHALL support a --preset navigation flag on the MCP server that expos
284284

285285
The system SHALL serialize concurrent decision consolidation processes using a cross-process advisory file lock to prevent draft loss.
286286

287+
> Decision recorded: 412817d2
288+
> Date: 2026-06-01
289+
290+
### Requirement: GateReasonMachineIsAPureTotalClassifier
291+
292+
The pre-commit gate's block-reason decision SHALL be computed by a pure, total
293+
classifier (`classifyGateState`, `src/core/decisions/gate-state.ts`) that maps the
294+
decision-store state (approved / verified / draft counts, consolidation recency,
295+
active count, git-repo + staged-source flags) to exactly one outcome: pass, or
296+
block with one of the canonical reasons (`verified`, `approved_not_synced`,
297+
`drafts_pending_consolidation`, `no_decisions_recorded`). The CLI gate command
298+
SHALL delegate the reason decision to this classifier and only build the
299+
user-facing payload around it. The classifier SHALL be **total** (every state maps
300+
to exactly one outcome), **deterministic / idempotent** (same state ⇒ same
301+
outcome), and **deadlock-free** (a blocking outcome always carries an actionable
302+
reason; a passing outcome never carries one), enforced by property tests
303+
(`gate-state.test.ts`).
304+
305+
#### Scenario: Every gate state yields exactly one outcome
306+
307+
- **GIVEN** any combination of decision-store counts, consolidation recency, and
308+
git staged-source state
309+
- **WHEN** the gate reason is classified
310+
- **THEN** the result is either a clean pass or a block with exactly one canonical
311+
reason — never blocked-without-reason and never passing-with-reason
312+
313+
### Requirement: AllDecisionStoreWritersUseAtomicCompareAndSwap
314+
315+
Every writer of the decision store (`pending.json`) — `record_decision`,
316+
`approve`/`reject`, consolidation, sync, and the HTTP API equivalents — SHALL
317+
persist through the single atomic compare-and-swap path
318+
(`updateDecisionStore` / `casUpdate`), so a write committed by one path is never
319+
silently clobbered by a stale snapshot from another. A raw, lock-free overwrite of
320+
the store by any production writer is prohibited.
321+
322+
#### Scenario: Rapid record_decision under background consolidation loses no draft
323+
324+
- **GIVEN** several `record_decision` calls in quick succession, each spawning a
325+
background consolidation that also writes the store
326+
- **WHEN** all writes complete
327+
- **THEN** the persisted store contains every recorded decision — no write is lost
328+
to a competing path on a different lock
329+
287330
> Decision recorded: 412817d2
288331
> Date: 2026-06-01
289332
### Requirement: AddSelecttestsMcpToolForCallgraphbasedTestImpactSelection

openspec/specs/mcp-handlers/spec.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,72 @@ The system SHALL The recall tool SHALL partition returned memories into authorit
116116

117117
> Decision recorded: dbe6a95e
118118
> Date: 2026-06-16
119+
120+
### Requirement: AuthoritativeRecallInvariant
121+
122+
The system SHALL guarantee, as a single named and test-enforced invariant, that **no
123+
memory whose freshness verdict is `drifted` or `orphaned` ever appears in an authoritative
124+
recall path unlabeled**. The authoritative recall paths are the `recall` tool and the
125+
memory (decision) section of `orient`. An `orphaned` memory SHALL be fully withheld from
126+
the authoritative set (surfaced only under `needsReanchoring` / `staleDecisions`); a
127+
`drifted` memory MAY remain in the authoritative set only when it carries an explicit
128+
`verify` label. This invariant is the operational definition of the project promise:
129+
*OpenLore never serves an unverified or stale fact as authoritative.* It SHALL be enforced
130+
by a property-based test (`memory-invariant.test.ts`) that generates arbitrary memories and
131+
arbitrary code mutations and asserts the property holds for every generated case.
132+
133+
#### Scenario: A drifted memory is excluded from the authoritative set unlabeled
134+
135+
- **GIVEN** a memory whose anchor verdict is `drifted`
136+
- **WHEN** `recall` or `orient` produces its response
137+
- **THEN** the memory does not appear in the authoritative set unlabeled; it is withheld or
138+
carries an explicit verify/non-authoritative label
139+
140+
#### Scenario: The invariant holds under generated mutation
141+
142+
- **GIVEN** an arbitrary memory and an arbitrary mutation to the code it anchors
143+
- **WHEN** the authoritative recall path is computed
144+
- **THEN** the authoritative set contains only `fresh` memories and explicitly-labeled
145+
`drifted` ones, never an `orphaned` memory
146+
147+
### Requirement: FreshnessFailsSafeTowardDistrust
148+
149+
The freshness computation (`anchorFreshness`, `hashSpan`) SHALL fail safe toward distrust:
150+
any ambiguity, hash collision, or boundary error SHALL bias the verdict toward `drifted` or
151+
`orphaned`, never toward a false `fresh`. A renamed, moved, or deleted symbol SHALL yield
152+
`orphaned` (or `drifted` only when a confident relocation is established). `hashSpan` SHALL
153+
slice spans by byte offset so multibyte UTF-8 boundaries hash correctly. A test that
154+
produces a false `fresh` SHALL be treated as a correctness failure; a false `orphaned` is
155+
acceptable. This is guarded by the adversarial suite (`anchor-adversarial.test.ts`).
156+
157+
#### Scenario: A forced collision does not produce false fresh
158+
159+
- **GIVEN** two distinct source spans
160+
- **WHEN** freshness is computed for a memory anchored to one after the other replaces it
161+
- **THEN** the verdict is `drifted` or `orphaned`, never `fresh` (distinct spans do not
162+
collide on the truncated content hash; a collision would fail the suite loudly)
163+
164+
#### Scenario: A multibyte span boundary hashes correctly
165+
166+
- **GIVEN** an anchored span whose start or end falls on a multibyte UTF-8 boundary
167+
- **WHEN** `hashSpan` computes the content hash before and after an unrelated edit elsewhere
168+
- **THEN** the hash is byte-correct and stable, producing `fresh` only when the span bytes
169+
are unchanged
170+
171+
### Requirement: ConcurrentMemoryWriteSafety
172+
173+
The `remember` and `record_decision` tools SHALL be safe under concurrent invocation: two
174+
concurrent writes to the same store SHALL NOT cause either write to be lost. On a write
175+
conflict the system SHALL re-read the current store and re-apply the pending
176+
append/upsert (compare-and-swap on a monotonic `sequence`), rather than overwrite the
177+
competing write.
178+
179+
#### Scenario: Concurrent remember calls lose no write
180+
181+
- **GIVEN** N concurrent `remember` calls against the same memory store
182+
- **WHEN** all calls complete
183+
- **THEN** the persisted store contains all N memories
184+
119185
### Requirement: DecisionsCarryStructuralAnchorsForSelfinvalidation
120186

121187
The system SHALL resolve structural anchors against the call graph when recording a decision, falling back to file-level anchors when no analysis is available.

src/api/decisions.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { createLLMService } from '../core/services/llm-service.js';
2020
import { isGitRepository, getChangedFiles, getFileDiff, getCommitMessages, resolveBaseRef, buildSpecMap } from '../core/drift/index.js';
2121
import {
2222
loadDecisionStore,
23-
saveDecisionStore,
23+
updateDecisionStore,
2424
upsertDecisions,
2525
patchDecision,
2626
makeDecisionId,
@@ -102,10 +102,15 @@ export async function openloreRecordDecision(options: RecordDecisionOptions): Pr
102102
syncedToSpecs: [],
103103
};
104104

105-
const updated = upsertDecisions(store, [decision]);
106-
await saveDecisionStore(rootPath, updated);
105+
// CAS upsert so concurrent writers never lose a draft; derive the id from the
106+
// committed store's sessionId so repeated records in a session dedupe correctly.
107+
let recordedId = id;
108+
await updateDecisionStore(rootPath, (s) => {
109+
recordedId = makeDecisionId(s.sessionId, domain, options.title);
110+
return upsertDecisions(s, [{ ...decision, id: recordedId, sessionId: s.sessionId }]);
111+
});
107112

108-
return { id };
113+
return { id: recordedId };
109114
}
110115

111116
// ============================================================================
@@ -182,12 +187,12 @@ export async function openloreConsolidateDecisions(
182187
: { verified: consolidated.map((d) => ({ ...d, status: 'verified' as const, confidence: 'medium' as const })), phantom: [], missing: [] };
183188
progress(onProgress, 'Verifying decisions', 'complete', `${verified.length} verified`);
184189

185-
let updatedStore = { ...store };
186-
for (const id of supersededIds) {
187-
updatedStore = patchDecision(updatedStore, id, { status: 'rejected' });
188-
}
189-
updatedStore = upsertDecisions(updatedStore, [...verified, ...phantom]);
190-
await saveDecisionStore(rootPath, updatedStore);
190+
// CAS persist onto the freshest store so a concurrently-recorded draft is kept.
191+
const updatedStore = await updateDecisionStore(rootPath, (s) => {
192+
let next = s;
193+
for (const id of supersededIds) next = patchDecision(next, id, { status: 'rejected' });
194+
return upsertDecisions(next, [...verified, ...phantom]);
195+
});
191196

192197
return { verified, phantom, missing, store: updatedStore };
193198
}

0 commit comments

Comments
 (0)