Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
f31979a
refactor(core): introduce runtime container composition root (phase 1a)
ethanj Apr 16, 2026
9f19199
refactor(app): remove misleading config override from createCoreRunti…
ethanj Apr 16, 2026
b4486c1
test: fix deployment-config port-binding regex drift after PR #6
ethanj Apr 16, 2026
7092859
docs(design): add Phase 1A singleton-hazard audit + follow-on sequence
ethanj Apr 16, 2026
39a84fa
docs(design): add minimum missing integration-test plan for Phase 1A …
ethanj Apr 16, 2026
c057326
test(app): add composed-boot parity integration test (phase 1a)
ethanj Apr 16, 2026
8a60031
test(app): cover PUT /memories/config write seam in composed-boot parity
ethanj Apr 16, 2026
b51ecdf
feat(api): return canonical search scope contract
ethanj Apr 16, 2026
e7b898f
refactor(api): make retrieval observability contract optional
ethanj Apr 16, 2026
788dbf7
refactor(app): route config mutation through runtime adapter
ethanj Apr 16, 2026
0ce8009
docs(design): audit phase 1b config singleton imports
ethanj Apr 16, 2026
8cc2aae
feat: thread runtime config through retrieval-policy callers in searc…
ethanj Apr 16, 2026
296cec1
test(harness): realign test schema on EMBEDDING_DIMENSIONS drift
ethanj Apr 16, 2026
8e763ef
refactor(search): thread runtime config into retrieval policy
ethanj Apr 16, 2026
2fc5633
refactor(search): remove module-global config dependence from agentic…
ethanj Apr 16, 2026
060501b
fix(search): restore runtime-compatible default on applyAgenticRetrieval
ethanj Apr 16, 2026
0c60d06
refactor(search): gate agentic retrieval via runtime policyConfig
ethanj Apr 16, 2026
3d169b4
refactor(search): gate iterative retrieval via runtime policyConfig
ethanj Apr 16, 2026
055fbcf
refactor(search): size MMR pool via runtime policyConfig
ethanj Apr 16, 2026
05d6b21
refactor(lineage): extract internal lineage emission seam
ethanj Apr 16, 2026
8809a39
refactor(search): report hybrid flag via runtime policyConfig in trace
ethanj Apr 16, 2026
8c37530
test(lineage): lock consolidation no-cmo seam behavior
ethanj Apr 16, 2026
ce36f2b
refactor(search): thread runtime policyConfig into runInitialRetrieval
ethanj Apr 16, 2026
2d11a2d
test(lineage): lock audn delete tombstone invariants
ethanj Apr 16, 2026
ed7af66
refactor(search): thread runtime policyConfig into abstract-hybrid fa…
ethanj Apr 16, 2026
b9df846
refactor(search): thread runtime policyConfig into entity-name co-ret…
ethanj Apr 16, 2026
788e5fc
test(lineage): lock backfill provenance-null invariants
ethanj Apr 16, 2026
22df929
refactor(search): thread runtime policyConfig into query augmentation
ethanj Apr 17, 2026
ea7bac4
test(lineage): extract legacy backfill helper
ethanj Apr 17, 2026
c8595a0
Thread runtime search policy config through retrieval seam
ethanj Apr 17, 2026
59b716e
Thread runtime rerank enable gate through search policy config
ethanj Apr 17, 2026
112c732
test(search): prove runtimeConfig override reaches expandQueryViaEnti…
ethanj Apr 17, 2026
37e27ec
refactor(search): thread reranker runtime config subset
ethanj Apr 17, 2026
9d41a07
chore: remove temporary commit message file
ethanj Apr 17, 2026
12ae16c
test(search): prove runtime config can enable agentic retrieval
ethanj Apr 17, 2026
2f31c19
Allow explicit staged-loading override in retrieval formatting
ethanj Apr 17, 2026
9da3d57
docs(search): update runtimeConfig JSDoc to reflect current threading…
ethanj Apr 17, 2026
f80d793
test(format): prove explicit full-loading override
ethanj Apr 17, 2026
19b985f
Thread ingest-time link generation config through runtime seam
ethanj Apr 17, 2026
75c670e
refactor(service): allow explicit config injection
ethanj Apr 17, 2026
f3c3e76
Test ingest runtime config forwarding into generateLinks
ethanj Apr 17, 2026
8953420
test(service): prove explicit config reaches ingest path
ethanj Apr 17, 2026
5e36d7e
refactor(runtime): narrow CoreRuntimeConfig interface
ethanj Apr 17, 2026
31fe6f4
Test quick-ingest config forwarding in MemoryService seam
ethanj Apr 17, 2026
cd6e8ce
refactor(search): narrow runtime config seam types
ethanj Apr 17, 2026
bab4d6c
refactor(runtime): pass config explicitly into MemoryService
ethanj Apr 17, 2026
246654b
Test workspace-ingest config forwarding in MemoryService seam
ethanj Apr 17, 2026
2e63c38
refactor(ingest): thread explicit ingest config seam
ethanj Apr 17, 2026
afc15ce
Thread route-layer config reads through injected adapter
ethanj Apr 17, 2026
560caa6
test: add config singleton import regression gate
ethanj Apr 17, 2026
58faf40
fix(test): broaden config singleton gate to catch multi-import patterns
ethanj Apr 17, 2026
9a8c286
fix(test): use file-level multiline matching in config singleton gate
ethanj Apr 17, 2026
89dfdbd
refactor(ingest): thread entropy and composite config
ethanj Apr 17, 2026
aa3e646
Thread namespace classification through runtime config seam
ethanj Apr 17, 2026
fe24be4
fix(test): add missing compositeMaxClusterSize to config mock
ethanj Apr 17, 2026
efe9634
refactor(lineage): remove config singleton from memory-lineage.ts
ethanj Apr 17, 2026
dbcd210
test: add max-cluster-size cap assertion for compositeGrouping
ethanj Apr 17, 2026
903a409
docs(test): refresh config seam truthfulness notes
ethanj Apr 17, 2026
56f37a6
test(lineage): prove consolidation forwards runtime config
ethanj Apr 17, 2026
e236792
Merge architecture2 into feat/phase-3-config-audit
ethanj Apr 17, 2026
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
58 changes: 58 additions & 0 deletions docs/design/phase-1b-config-import-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Phase 1B config import audit


Total `import { config } from` sites under `src/`: **51**

| File | Initial class |
| --- | --- |
| `src/services/memory-audn.ts` | mixed or construction-time |
| `src/services/memory-ingest.ts` | mixed or construction-time |
| `src/services/retrieval-format.ts` | mixed or construction-time |
| `src/services/memory-crud.ts` | mixed or construction-time |
| `src/services/retrieval-policy.ts` | mixed or construction-time |
| `src/services/reranker.ts` | mixed or construction-time |
| `src/services/write-security.ts` | request-time/module-read |
| `src/services/consensus-validation.ts` | request-time/module-read |
| `src/services/embedding.ts` | mixed or construction-time |
| `src/services/agentic-retrieval.ts` | request-time/module-read |
| `src/services/chunked-extraction.ts` | mixed or construction-time |
| `src/services/consensus-extraction.ts` | constant-or-env bootstrap |
| `src/services/conflict-policy.ts` | mixed or construction-time |
| `src/services/__tests__/retrieval-trace.test.ts` | mixed or construction-time |
| `src/services/extraction-cache.ts` | request-time/module-read |
| `src/services/__tests__/current-state-retrieval-regression.test.ts` | constant-or-env bootstrap |
| `src/services/cost-telemetry.ts` | mixed or construction-time |
| `src/services/lesson-service.ts` | request-time/module-read |
| `src/services/deferred-audn.ts` | request-time/module-read |
| `src/services/__tests__/staged-loading.test.ts` | mixed or construction-time |
| `src/__tests__/route-validation.test.ts` | mixed or construction-time |
| `src/services/consolidation-service.ts` | request-time/module-read |
| `src/__tests__/smoke.test.ts` | request-time/module-read |
| `src/services/composite-grouping.ts` | mixed or construction-time |
| `src/services/query-expansion.ts` | mixed or construction-time |
| `src/services/retrieval-trace.ts` | constant-or-env bootstrap |
| `src/services/__tests__/deferred-audn.test.ts` | request-time/module-read |
| `src/services/search-pipeline.ts` | mixed or construction-time |
| `src/services/memory-search.ts` | mixed or construction-time |
| `src/services/__tests__/write-security.test.ts` | request-time/module-read |
| `src/services/llm.ts` | mixed or construction-time |
| `src/services/memory-storage.ts` | mixed or construction-time |
| `src/app/__tests__/composed-boot-parity.test.ts` | mixed or construction-time |
| `src/app/__tests__/runtime-container.test.ts` | request-time/module-read |
| `src/db/repository-lessons.ts` | mixed or construction-time |
| `src/db/migrate.ts` | constant-or-env bootstrap |
| `src/db/repository-entities.ts` | mixed or construction-time |
| `src/db/repository-read.ts` | mixed or construction-time |
| `src/db/repository-links.ts` | mixed or construction-time |
| `src/db/query-helpers.ts` | mixed or construction-time |
| `src/db/repository-vector-search.ts` | mixed or construction-time |
| `src/db/agent-trust-repository.ts` | mixed or construction-time |
| `src/db/__tests__/test-fixtures.ts` | mixed or construction-time |
| `src/db/__tests__/claim-slot-backfill.test.ts` | mixed or construction-time |
| `src/db/__tests__/links.test.ts` | mixed or construction-time |
| `src/db/repository-representations.ts` | mixed or construction-time |
| `src/db/__tests__/dual-write-representations.test.ts` | request-time/module-read |
| `src/db/__tests__/mutation-audit.test.ts` | mixed or construction-time |
| `src/db/__tests__/temporal-invalidation.test.ts` | mixed or construction-time |
| `src/db/__tests__/temporal-neighbors.test.ts` | mixed or construction-time |
| `src/db/__tests__/canonical-memory-objects.test.ts` | mixed or construction-time |
106 changes: 106 additions & 0 deletions src/__tests__/config-singleton-audit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Config singleton import regression gate.
*
* Counts the non-test source files that bind the module-level config
* singleton value from config.js (any import/export pattern). The threshold
* should only move DOWN as config-threading PRs land. Any PR that adds
* a new singleton import must raise the threshold explicitly — that
* friction is the point.
*
* This test does not depend on a live database or runtime — it reads
* source files statically, matching the pattern in
* deployment-config.test.ts.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve, dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = resolve(__dirname, '..');

/**
* Maximum allowed non-test source files that bind the runtime config
* singleton value from config.js. Ratchet this DOWN after each
* config-threading PR lands.
* Current baseline: 34 files after the ingest/lineage config-threading
* cleanup removed those last singleton reads from `memory-lineage.ts`
* and `memory-ingest.ts`.
* Includes multi-import forms (e.g. `import { config, updateRuntimeConfig }`)
* and re-exports (e.g. `export { config } from`).
*/
const MAX_SINGLETON_IMPORTS = 34;

/**
* Matches any import or re-export that binds the `config` value (not
* just a type) from a path ending in `config.js` or `config`. Covers
* single-line and multiline import blocks:
* import { config } from '../config.js'
* import { config, updateRuntimeConfig } from '../config.js'
* import {\n config,\n updateRuntimeConfig,\n} from '../config.js'
* export { config, ... } from './config.js'
* Excludes `import type`-only statements.
*/
const CONFIG_BINDING_RE = /(?:import|export)\s*\{[^}]*\bconfig\b[^}]*\}\s*from\s*['"][^'"]*config/s;
const IMPORT_TYPE_ONLY_RE = /import\s+type\s*\{/;

function collectTsFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
results.push(...collectTsFiles(full));
} else if (entry.isFile() && extname(entry.name) === '.ts') {
results.push(full);
}
}
return results;
}

function findSingletonImporters(): string[] {
const files = collectTsFiles(SRC);
const matches: string[] = [];
for (const filePath of files) {
const content = readFileSync(filePath, 'utf-8');
// Find all import/export blocks from a config path that bind `config`
const hits = content.match(new RegExp(CONFIG_BINDING_RE.source, 'gs')) ?? [];
const hasRuntimeBinding = hits.some((hit) => !IMPORT_TYPE_ONLY_RE.test(hit));
if (hasRuntimeBinding) matches.push(filePath);
}
return matches.sort();
}

describe('config singleton regression gate', () => {
it(`non-test source files importing config singleton must not exceed ${MAX_SINGLETON_IMPORTS}`, () => {
const files = findSingletonImporters();

expect(files.length).toBeLessThanOrEqual(MAX_SINGLETON_IMPORTS);

// Print the list on failure so the developer knows exactly which
// files to inspect or thread.
if (files.length > MAX_SINGLETON_IMPORTS) {
console.error(
`Config singleton imports (${files.length}) exceed threshold (${MAX_SINGLETON_IMPORTS}):\n` +
files.map((f) => ` ${f}`).join('\n'),
);
}
});

it('threshold is not stale (count should be close to threshold)', () => {
const files = findSingletonImporters();
const slack = MAX_SINGLETON_IMPORTS - files.length;

// If the threshold has more than 5 files of slack, a threading PR
// landed without ratcheting the threshold down. Warn but don't fail
// — the primary gate is the upper-bound test above.
if (slack > 5) {
console.warn(
`Config singleton threshold has ${slack} files of slack ` +
`(threshold=${MAX_SINGLETON_IMPORTS}, actual=${files.length}). ` +
`Consider ratcheting MAX_SINGLETON_IMPORTS down to ${files.length + 2}.`,
);
}
});
});
178 changes: 178 additions & 0 deletions src/__tests__/memory-route-config-seam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Route-level config seam tests for createMemoryRouter.
*
* Verifies that read-side route config now comes from the injected adapter
* rather than the module-level singleton for health/config responses and
* search-limit clamping.
*/

import express from 'express';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter } from '../routes/memories.js';
import type { MemoryService } from '../services/memory-service.js';

interface BootedApp {
baseUrl: string;
close: () => Promise<void>;
}

interface MutableRouteConfig {
retrievalProfile: string;
embeddingProvider: 'openai';
embeddingModel: string;
llmProvider: 'openai';
llmModel: string;
clarificationConflictThreshold: number;
maxSearchResults: number;
hybridSearchEnabled: boolean;
iterativeRetrievalEnabled: boolean;
entityGraphEnabled: boolean;
crossEncoderEnabled: boolean;
agenticRetrievalEnabled: boolean;
repairLoopEnabled: boolean;
}

async function bindEphemeral(app: ReturnType<typeof express>): Promise<BootedApp> {
const server = app.listen(0);
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
return {
baseUrl: `http://localhost:${port}`,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}

describe('memory route config seam', () => {
let booted: BootedApp;
let routeConfig: MutableRouteConfig;
const search = vi.fn();

beforeAll(async () => {
routeConfig = {
retrievalProfile: 'route-adapter-profile',
embeddingProvider: 'openai',
embeddingModel: 'adapter-embedding-model',
llmProvider: 'openai',
llmModel: 'adapter-llm-model',
clarificationConflictThreshold: 0.91,
maxSearchResults: 3,
hybridSearchEnabled: true,
iterativeRetrievalEnabled: false,
entityGraphEnabled: true,
crossEncoderEnabled: true,
agenticRetrievalEnabled: false,
repairLoopEnabled: true,
};

search.mockResolvedValue({
memories: [],
injectionText: '',
citations: [],
retrievalMode: 'flat',
});

const service = {
search,
fastSearch: vi.fn(),
workspaceSearch: vi.fn(),
ingest: vi.fn(),
quickIngest: vi.fn(),
storeVerbatim: vi.fn(),
workspaceIngest: vi.fn(),
expand: vi.fn(),
expandInWorkspace: vi.fn(),
list: vi.fn(),
listInWorkspace: vi.fn(),
getStats: vi.fn(),
consolidate: vi.fn(),
executeConsolidation: vi.fn(),
evaluateDecay: vi.fn(),
archiveDecayed: vi.fn(),
checkCap: vi.fn(),
getMutationSummary: vi.fn(),
getRecentMutations: vi.fn(),
getAuditTrail: vi.fn(),
getLessons: vi.fn(),
getLessonStats: vi.fn(),
reportLesson: vi.fn(),
deactivateLesson: vi.fn(),
reconcileDeferred: vi.fn(),
resetBySource: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
} as unknown as MemoryService;

const configRouteAdapter = {
current: () => ({ ...routeConfig }),
update: (updates: { maxSearchResults?: number }) => {
if (updates.maxSearchResults !== undefined) {
routeConfig.maxSearchResults = updates.maxSearchResults;
}
return Object.keys(updates);
},
};

const app = express();
app.use(express.json());
app.use('/memories', createMemoryRouter(service, configRouteAdapter));
booted = await bindEphemeral(app);
});

beforeEach(() => {
search.mockClear();
routeConfig.maxSearchResults = 3;
});

afterAll(async () => {
await booted.close();
});

it('serves health/config payloads from the injected adapter snapshot', async () => {
const healthRes = await fetch(`${booted.baseUrl}/memories/health`);
expect(healthRes.status).toBe(200);
const healthBody = await healthRes.json();
expect(healthBody.config.retrieval_profile).toBe('route-adapter-profile');
expect(healthBody.config.max_search_results).toBe(3);

const putRes = await fetch(`${booted.baseUrl}/memories/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_search_results: 7 }),
});
expect(putRes.status).toBe(200);
const putBody = await putRes.json();
expect(putBody.applied).toContain('maxSearchResults');
expect(putBody.config.max_search_results).toBe(7);

const updatedHealthRes = await fetch(`${booted.baseUrl}/memories/health`);
const updatedHealthBody = await updatedHealthRes.json();
expect(updatedHealthBody.config.max_search_results).toBe(7);
});

it('clamps search limits using the injected adapter snapshot', async () => {
await fetch(`${booted.baseUrl}/memories/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: 'user-1',
query: 'route seam query',
limit: 50,
}),
});

expect(search).toHaveBeenCalledWith(
'user-1',
'route seam query',
undefined,
3,
undefined,
undefined,
undefined,
{
retrievalMode: undefined,
tokenBudget: undefined,
},
);
});
});
19 changes: 6 additions & 13 deletions src/__tests__/route-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,13 @@ vi.mock('../services/embedding.js', async (importOriginal) => {
});

import { pool } from '../db/pool.js';
import { config } from '../config.js';
import { MemoryRepository } from '../db/memory-repository.js';
import { ClaimRepository } from '../db/claim-repository.js';
import { MemoryService } from '../services/memory-service.js';
import { createMemoryRouter } from '../routes/memories.js';
import { setupTestSchema } from '../db/__tests__/test-fixtures.js';
import express from 'express';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const TEST_USER = 'route-validation-test-user';
const VALID_UUID = '00000000-0000-0000-0000-000000000001';
const INVALID_UUID = 'not-a-uuid';
Expand All @@ -42,9 +38,7 @@ const app = express();
app.use(express.json());

beforeAll(async () => {
const raw = readFileSync(resolve(__dirname, '../db/schema.sql'), 'utf-8');
const sql = raw.replace(/\{\{EMBEDDING_DIMENSIONS\}\}/g, String(config.embeddingDimensions));
await pool.query(sql);
await setupTestSchema(pool);

const repo = new MemoryRepository(pool);
const claimRepo = new ClaimRepository(pool);
Expand Down Expand Up @@ -124,7 +118,7 @@ describe('GET /memories/list — source_site filter', () => {
});

describe('POST /memories/search — scope and observability contract', () => {
it('returns canonical user scope and only includes observability fields that are actually emitted', async () => {
it('returns canonical user scope and only includes observability sections that the retrieval path actually emitted', async () => {
const res = await fetch(`${baseUrl}/memories/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -138,10 +132,9 @@ describe('POST /memories/search — scope and observability contract', () => {
expect(res.status).toBe(200);
const body = await res.json();
expect(body.scope).toEqual({ kind: 'user', userId: TEST_USER });
expect(body.observability).toBeDefined();
expect(body.observability.retrieval ?? null).toBe(null);
expect(body.observability.packaging).toBeDefined();
expect(body.observability.assembly).toBeDefined();
expect(body.observability?.retrieval).toBeUndefined();
expect(body.observability?.packaging?.packageType).toBe('subject-pack');
expect(body.observability?.assembly?.blocks).toEqual(['subject']);
});

it('returns canonical workspace scope for workspace searches', async () => {
Expand Down
Loading