agent/php/tests/Feature/Brain/OrgScopingTest.php

325 lines
11 KiB
PHP
Raw Normal View History

fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Illuminate\Auth\Access\AuthorizationException;
use Core\Mod\Agentic\Jobs\DeleteFromIndex;
use Core\Mod\Agentic\Jobs\EmbedMemory;
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Http\Request as HttpRequest;
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
function orgScopingBrainService(): BrainService
{
return new BrainService(
ollamaUrl: 'https://ollama.test',
qdrantUrl: 'https://qdrant.test',
collection: 'openbrain',
embeddingModel: 'embeddinggemma',
verifySsl: false,
elasticsearchUrl: 'https://elasticsearch.test',
);
}
function orgScopingMemory(int $workspaceId, array $attributes = []): BrainMemory
{
return BrainMemory::create(array_merge([
'workspace_id' => $workspaceId,
'agent_id' => 'virgil',
'type' => 'context',
'content' => 'Organisation-scoped OpenBrain memory.',
'confidence' => 0.85,
'org' => 'core',
'project' => 'agent',
], $attributes));
}
function orgScopingBindRequestContext(object $workspace, array $context = []): void
{
$request = HttpRequest::create('/api/v1/mcp/tools/call', 'POST');
$request->attributes->set('workspace', $workspace);
$request->attributes->set('workspace_id', $workspace->id);
$request->attributes->set('mcp_workspace_context', array_merge([
'workspace_id' => $workspace->id,
'workspace' => $workspace,
], $context));
app()->instance('request', $request);
}
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
test('OrgScoping_remember_recall_Good_persists_org_and_recalls_with_matching_org', function (): void {
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
$brain = orgScopingBrainService();
$memory = $brain->remember([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'Core remembers its own scoped knowledge.',
'org' => 'core',
'project' => 'agent',
'confidence' => 0.92,
]);
Http::fake([
'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]),
'https://qdrant.test/collections/openbrain/points/search' => Http::response([
'result' => [
['id' => $memory->id, 'score' => 0.91],
],
]),
]);
$result = $brain->recall('core scoped knowledge', 5, ['org' => 'core'], $workspace->id);
expect($memory->fresh()?->getAttribute('org'))->toBe('core')
->and($result['memories'])->toHaveCount(1)
->and($result['memories'][0]['id'])->toBe($memory->id)
->and($result['memories'][0]['org'])->toBe('core');
Http::assertSent(fn (ClientRequest $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search'
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
&& $request->method() === 'POST'
&& $request['filter']['must'] === [
['key' => 'workspace_id', 'match' => ['value' => $workspace->id]],
['key' => 'org', 'match' => ['value' => 'core']],
]);
});
test('OrgScoping_recall_Bad_rejects_an_unauthorised_org_before_any_qdrant_query', function (): void {
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
$brain = orgScopingBrainService();
$memory = $brain->remember([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'Core-only memory should not leak into another organisation scope.',
'org' => 'core',
'project' => 'agent',
'confidence' => 0.88,
]);
Http::fake([
'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]),
'https://qdrant.test/collections/openbrain/points/search' => Http::response(['result' => []]),
]);
expect($memory->fresh()?->getAttribute('org'))->toBe('core');
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
expect(fn () => $brain->recall('core-only memory', 5, ['org' => 'other-org'], $workspace->id))
->toThrow(AuthorizationException::class, "Organisation scope 'other-org' is not authorised for this authenticated workspace.");
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
Http::assertNothingSent();
});
test('OrgScoping_remember_Bad_rejects_an_unauthorised_org_without_inserting_a_memory', function (): void {
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
$brain = orgScopingBrainService();
expect(fn () => $brain->remember([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'This should never be stored for another organisation.',
'org' => 'evil',
'project' => 'agent',
'confidence' => 0.9,
]))->toThrow(AuthorizationException::class, "Organisation scope 'evil' is not authorised for this authenticated workspace.");
expect(BrainMemory::query()->count())->toBe(0);
Queue::assertNotPushed(EmbedMemory::class);
});
test('OrgScoping_forget_Bad_rejects_forgetting_a_memory_from_another_org', function (): void {
Queue::fake();
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace);
$brain = orgScopingBrainService();
$memory = orgScopingMemory($workspace->id, [
'content' => 'Other org memory must not be forgotten by core.',
'org' => 'evil',
]);
expect(fn () => $brain->forget($memory->id))
->toThrow(AuthorizationException::class, "Organisation scope 'evil' is not authorised for this authenticated workspace.");
expect(BrainMemory::query()->find($memory->id))->not->toBeNull();
Queue::assertNotPushed(DeleteFromIndex::class);
fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md. - Gap A (org scoping persisted on writes): new migration adds `org` nullable+indexed column to brain_memories; BrainMemory fillable; RememberKnowledge action forwards org; BrainService::remember persists it. - Gap B (supersede/forget Elastic cleanup): BrainService::forget dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede path dispatches cleanup for the old memory id before replacing it. DeleteFromIndex itself untouched — already handled both indexes. - Gap C (brain:reindex flags): --org, --project, --stale (null OR >14d old), --dry-run (count+stop), --elastic-only added to the artisan command. - Gap D (MCP schemas expose org): brain_remember, brain_recall, brain_list now accept `org` in input schema + forward into action/service. - Gap E (resilience uneven): brain_list now wrapped in withCircuitBreaker('brain', ...) matching the pattern used by BrainRemember/Recall/Forget. BrainService gains retryableHttp() helper — 100/300/900ms exponential backoff, retries only on 5xx + connection errors, not on 4xx. Qdrant calls route through it; Ollama left alone (EmbedMemory job has its own retry). Tests (Good/Bad/Ugly per gap): - Feature/Brain/OrgScopingTest.php - Feature/Brain/SupersedeForgetIndexCleanupTest.php - Feature/Brain/ReindexFlagsTest.php - Feature/Mcp/BrainSchemaOrgTest.php - Feature/Brain/CircuitBreakerTest.php php -l clean on all 13 files. Pest binary not in this checkout — CI path validates the full suite. Closes tasks.lthn.sh/view.php?id=107 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
});
test('OrgScoping_list_Ugly_filters_memories_by_org', function (): void {
$workspace = createWorkspace();
$otherWorkspace = createWorkspace();
$coreMemory = orgScopingMemory($workspace->id, ['content' => 'Core memory', 'org' => 'core']);
orgScopingMemory($workspace->id, ['content' => 'Other org memory', 'org' => 'other-org']);
orgScopingMemory($otherWorkspace->id, ['content' => 'Other workspace memory', 'org' => 'core']);
$result = (new BrainList)->handle([
'org' => 'core',
'limit' => 10,
], [
'workspace_id' => $workspace->id,
]);
expect($result['success'])->toBeTrue()
->and($result['count'])->toBe(1)
->and($result['memories'])->toHaveCount(1)
->and($result['memories'][0]['id'])->toBe($coreMemory->id)
->and($result['memories'][0]['org'])->toBe('core');
});
test('OrgScoping_search_Good_limits_results_to_authorised_orgs_and_global_memories', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
$coreMemory = orgScopingMemory($workspace->id, [
'content' => 'Core scoped discovery memory.',
'org' => 'core',
]);
$globalMemory = orgScopingMemory($workspace->id, [
'content' => 'Global discovery memory.',
'org' => null,
'project' => null,
]);
$otherOrgMemory = orgScopingMemory($workspace->id, [
'content' => 'Other organisation discovery memory.',
'org' => 'other-org',
]);
Http::fake([
'https://elasticsearch.test/brain_memories/_search' => Http::response([
'hits' => [
'hits' => [
['_id' => $globalMemory->id, '_score' => 3.5],
['_id' => $otherOrgMemory->id, '_score' => 2.5],
['_id' => $coreMemory->id, '_score' => 1.5],
],
],
]),
]);
$result = $brain->search('discovery memory', $workspace->id, [], 5);
expect(array_column($result, 'id'))->toBe([
$globalMemory->id,
$coreMemory->id,
])
->and($result[0]['score'])->toBe(3.5)
->and($result[1]['score'])->toBe(1.5);
Http::assertSent(fn (ClientRequest $request): bool => $request->url() === 'https://elasticsearch.test/brain_memories/_search'
&& $request->method() === 'POST'
&& $request['query']['bool']['filter'] === [
['term' => ['workspace_id' => $workspace->id]],
]);
});
test('OrgScoping_discoverTags_Bad_rejects_an_unauthorised_org_filter', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
expect(fn () => $brain->discoverTags($workspace->id, 'other-org'))
->toThrow(AuthorizationException::class, "Organisation scope 'other-org' is not authorised for this authenticated workspace.");
});
test('OrgScoping_discoverTags_Good_limits_results_to_authorised_orgs_and_global_memories', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
orgScopingMemory($workspace->id, [
'content' => 'Core tag memory.',
'org' => 'core',
'tags' => ['core-tag'],
]);
orgScopingMemory($workspace->id, [
'content' => 'Second core tag memory.',
'org' => 'core',
'tags' => ['core-tag'],
]);
orgScopingMemory($workspace->id, [
'content' => 'Global tag memory.',
'org' => null,
'project' => null,
'tags' => ['global-tag'],
]);
orgScopingMemory($workspace->id, [
'content' => 'Other org tag memory.',
'org' => 'other-org',
'tags' => ['other-tag'],
]);
$result = $brain->discoverTags($workspace->id);
expect($result)->toBe([
['name' => 'core-tag', 'count' => 2],
['name' => 'global-tag', 'count' => 1],
]);
});
test('OrgScoping_listScopes_Good_limits_scope_tree_to_authorised_orgs_and_global_memories', function (): void {
$workspace = createWorkspace();
$workspace->setAttribute('slug', 'core');
orgScopingBindRequestContext($workspace, [
'authorised_orgs' => ['core'],
]);
$brain = orgScopingBrainService();
orgScopingMemory($workspace->id, [
'content' => 'Core agent memory.',
'org' => 'core',
'project' => 'agent',
]);
orgScopingMemory($workspace->id, [
'content' => 'Global shared memory.',
'org' => null,
'project' => null,
]);
orgScopingMemory($workspace->id, [
'content' => 'Other organisation memory.',
'org' => 'other-org',
'project' => 'agent',
]);
$result = $brain->listScopes($workspace->id);
expect($result)->toBe([
[
'org' => null,
'count' => 1,
'projects' => [],
],
[
'org' => 'core',
'count' => 1,
'projects' => [
['name' => 'agent', 'count' => 1],
],
],
]);
});