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
19 changes: 19 additions & 0 deletions changelog.d/store-topology-fail-loud.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- Store topology fails loudly. Every load audits the summary archive for
crossed ownership (a summary whose leaves are not contiguous among
chunk-owned messages in store order — issue #122's cross-era merges,
restore/branch interleavings, hand surgery). `topologyPolicy: 'reject'`
(default) throws `StoreTopologyError` from `initialize`, so
`ContextManager.open` refuses the store until it is repaired;
`'report'` logs the violations at error level and reports them through
`getCompressionDebt().topologyViolations` (state `critical`). A kv-unified
config that opts into gap handling (`preserveGapBearingSummaries` or
`treeifyNonContiguousSummaries`) defaults to `'report'`.
`scripts/audit-topology.ts` runs the same audit read-only on a store path.
- Merge adjacency is judged in store order, not chunk-record order, so a chunk
minted late over an early message can no longer join the frontier's merge run
(the second half of #122). Demand-path merges (`enqueueMergeForRange`, #95)
are split into strictly adjacent runs like the threshold path. `executeMerge`
refuses any group that is not one level below the target and strictly adjacent
in store order: no model call, the entry moves into the merge quarantine with
outcome `topology_violation`, and `getCompressionDebt().topologyRefusals` /
state `critical` say so. A crossed node is never minted.
57 changes: 57 additions & 0 deletions scripts/audit-topology.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Audit a store's summary topology: every summary whose leaves are not
* contiguous among chunk-owned messages in store order (crossed ownership —
* issue #122 cross-era merges, restore/branch interleavings, hand surgery).
* Read-only; the same audit `initialize` runs (topologyPolicy).
*
* Usage:
* node dist/scripts/audit-topology.js <store-path> [--namespace <ns>] [--json]
*
* Exit code 0 = clean, 2 = violations found, 1 = could not open.
* Run it on a stopped resident's store or a copy: opening a chronicle store
* takes its lock and may rewrite its state index.
*/

import { ContextManager, AutobiographicalStrategy } from '../src/index.js';

async function main(): Promise<void> {
const args = process.argv.slice(2);
const storePath = args.find((a) => !a.startsWith('--'));
if (!storePath) {
console.error('Usage: audit-topology <store-path> [--namespace <ns>] [--json]');
process.exit(1);
}
const nsAt = args.indexOf('--namespace');
const namespace = nsAt >= 0 ? args[nsAt + 1] : undefined;
const json = args.includes('--json');
const strategy = new AutobiographicalStrategy({
adaptiveResolution: true,
hierarchical: true,
autoTickOnNewMessage: false,
topologyPolicy: 'report',
});
const membrane = { complete: async () => ({ content: [{ type: 'text', text: '[audit]' }] }) };
const manager = await ContextManager.open({
path: storePath, strategy, membrane: membrane as never, ...(namespace ? { namespace } : {}),
});
try {
const violations = strategy.getTopologyViolations();
const debt = strategy.getCompressionDebt();
if (json) {
console.log(JSON.stringify({ store: storePath, namespace, violations, debt }, null, 2));
} else {
console.log(`${storePath}${namespace ? ` (${namespace})` : ''}: ${manager.getMessageCount()} messages, ` +
`${violations.length} topology violation(s), compression debt ${debt.state}`);
for (const v of violations) {
console.log(` L${v.level} ${v.id}: ${v.leafCount} leaves ${v.span.first}..${v.span.last}, ` +
`${v.holes} hole(s) e.g. ${v.holeSample.join(',')}` +
`${v.holeOwners.length ? ` owned by ${v.holeOwners.join(',')}` : ''}`);
}
}
process.exitCode = violations.length > 0 ? 2 : 0;
} finally {
manager.close();
}
}

main().catch((error) => { console.error(error); process.exit(1); });
13 changes: 11 additions & 2 deletions src/context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,18 @@ export class ContextManager {
auxiliaryStores,
);

// Initialize strategy
// Initialize strategy. A strategy that refuses the store (e.g.
// StoreTopologyError) must not leave a store we opened locked behind a
// rejected promise: release it, then rethrow.
const openingBranch = observeStoreBranch(store);
await manager.initializeStrategy(openingBranch);
try {
await manager.initializeStrategy(openingBranch);
} catch (error) {
if (ownsStore) {
try { store.close(); } catch { /* the initialize error is the one to report */ }
}
throw error;
}
manager.initialized = true;

return manager;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type { ConfigLayer, ConfigResolutionSemantics, EffectiveConfigReport } fr
// classifyInferenceError); exporting them from the root gives consumers a
// real `instanceof` instead of stringly-typed `err.name` matching.
export { OverBudgetError, UncoveredDropError } from './adaptive/picker.js';
export { StoreTopologyError, type TopologyViolation } from './strategies/autobiographical.js';
export type { OverBudgetDiagnostics } from './adaptive/picker.js';

// Types
Expand Down
Loading
Loading