diff --git a/.gitea/workflows/mcp-acceptance-preview.yml b/.gitea/workflows/mcp-acceptance-preview.yml index 36bdf2d8..05671f3a 100644 --- a/.gitea/workflows/mcp-acceptance-preview.yml +++ b/.gitea/workflows/mcp-acceptance-preview.yml @@ -16,7 +16,7 @@ permissions: packages: write jobs: backend-preview: - if: ${{ gitea.event_name == 'workflow_dispatch' }} + if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }} runs-on: ubuntu-latest timeout-minutes: 40 env: @@ -24,6 +24,7 @@ jobs: EXPECTED_SHA: ${{ inputs.expected_sha }} DISPATCH_SHA: ${{ gitea.sha }} DISPATCH_EVENT: ${{ gitea.event_name }} + DISPATCH_REF: ${{ gitea.ref }} REPOSITORY: ${{ gitea.repository }} RUN_ID: ${{ gitea.run_id }} RUN_ATTEMPT: ${{ gitea.run_attempt }} @@ -42,6 +43,7 @@ jobs: run: | set -euo pipefail test "$DISPATCH_EVENT" = workflow_dispatch + test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103 [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] test "$EXPECTED_SHA" = "$DISPATCH_SHA" test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" @@ -139,7 +141,7 @@ jobs: if-no-files-found: error retention-days: 14 frontend-preview: - if: ${{ gitea.event_name == 'workflow_dispatch' }} + if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }} runs-on: ubuntu-latest timeout-minutes: 40 env: @@ -147,6 +149,7 @@ jobs: EXPECTED_SHA: ${{ inputs.expected_sha }} DISPATCH_SHA: ${{ gitea.sha }} DISPATCH_EVENT: ${{ gitea.event_name }} + DISPATCH_REF: ${{ gitea.ref }} REPOSITORY: ${{ gitea.repository }} RUN_ID: ${{ gitea.run_id }} RUN_ATTEMPT: ${{ gitea.run_attempt }} @@ -178,6 +181,7 @@ jobs: run: | set -euo pipefail test "$DISPATCH_EVENT" = workflow_dispatch + test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103 [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] test "$EXPECTED_SHA" = "$DISPATCH_SHA" test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" diff --git a/backend/mcp-coverage.json b/backend/mcp-coverage.json index 870f46e1..a3c5388f 100644 --- a/backend/mcp-coverage.json +++ b/backend/mcp-coverage.json @@ -576,6 +576,13 @@ "operations": [], "reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет." }, + { + "method": "GET", + "path": "/api/mcp/database/receipts/:id/audit-status", + "status": "excluded", + "operations": [], + "reason": "Человеческая административная сверка аудита внешней квитанции восстановления: управление самим MCP, без отдельного AI-инструмента." + }, { "method": "GET", "path": "/api/mcp/devices/:serverId/:resource", diff --git a/backend/package.json b/backend/package.json index fdc4fa2a..5d36917f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,7 +54,7 @@ "test:ospf-optimizer": "tsx src/services/ospf-cost-optimization.test.ts && tsx src/modules/mcp/ospf-legacy-source.test.ts && tsx src/services/ospf-legacy-optimization.test.ts && tsx src/services/ospf-template-flags.test.ts && tsx src/services/ospf-route-proof.test.ts && tsx src/services/ospf-link-catalog.test.ts && tsx src/services/ospf-identity-catalog.test.ts && tsx src/services/ospf-template-cost-write.test.ts && tsx src/services/ospf-template-cost-http.test.ts && tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts", "test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts", "test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts && tsx src/services/acme-settings-test.test.ts && tsx src/services/acme-settings-update-model.test.ts && tsx src/modules/mcp/acme-settings-actions.test.ts", - "test:system-database": "tsx src/services/bounded-database-dump.test.ts && tsx src/services/database-dump-connection.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/services/system-database-schema-readiness.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts && tsx src/services/database-control-archive-boundary.test.ts && tsx src/db/database-schema-control-model.test.ts && tsx src/db/database-schema-control.test.ts && tsx src/db/controlled-migrations.test.ts && tsx src/db/controlled-schema-verification.test.ts && tsx --test src/db/migration-data-inventory.test.ts", + "test:system-database": "tsx src/services/bounded-database-dump.test.ts && tsx src/services/database-dump-connection.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx --test src/services/detached-database-admission.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/services/system-database-schema-readiness.test.ts && tsx --test src/routes/mcp-oauth-database-admission.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts && tsx src/services/database-control-archive-boundary.test.ts && tsx src/db/database-schema-control-model.test.ts && tsx src/db/database-schema-control.test.ts && tsx src/db/controlled-migrations.test.ts && tsx src/db/controlled-schema-verification.test.ts && tsx --test src/db/migration-data-inventory.test.ts && tsx --test src/services/database-maintenance-startup.test.ts && tsx --test src/services/database-restore-audit.test.ts && tsx --test src/services/database-maintenance-resolution.test.ts && tsx --test scripts/database-maintenance-resolution.test.mts", "test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:sidebar-counts && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy && npm run test:mcp:module-completion && npm run test:mcp:loopback-agent && npm run test:server-probe-management && npm run test:server-poll-management && npm run test:mcp:routing-authority && npm run test:mcp:native-readers && npm run test:mcp:chr-acquisition && npm run test:mcp:shared-context && npm run test:mcp:telemetry-configuration", "test:server-privacy": "tsx ../packages/contracts/src/servers.test.ts && tsx src/routes/server-secrets-sql.test.ts && tsx --test src/services/server-connection-test.test.ts", "test:mcp:flow-rebuild:pg": "node scripts/test-mcp-postgres.mjs --flow-rebuild-mcp", @@ -140,7 +140,7 @@ "test:server-poll-storage-fresh:pg": "node scripts/test-mcp-postgres.mjs --server-poll-storage-fresh --restore", "test:partition-writers": "tsx src/services/partition-write-transaction.test.ts && tsx src/services/collector-partition-writes.test.ts && tsx src/db/partitions-writer.test.ts && tsx src/db/migrate-partition-admission.test.ts && tsx src/db/migration-manifest.test.ts && tsx src/db/sqlite-import-partition-admission.test.ts && tsx src/services/system-database-partition-restore.test.ts && tsx src/services/traffic-flow-write-transaction.test.ts && tsx src/services/traffic-flow-facts-rebuild-cli.test.ts", "test:mcp:routing-authority": "tsx --test src/modules/mcp/routing-switch-ledger-contract.test.ts && tsx src/modules/mcp/routing-profile-authority.test.ts && tsx src/modules/mcp/routing-recovery-authority.test.ts && tsx src/modules/mcp/routing-reservation.test.ts && tsx src/modules/mcp/routing-community-reserved-source.test.ts && tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx --test src/test/committed-scoped-checkpoint-observer.test.ts && node --test src/modules/mcp/routing-forward-transport-charge.test.cjs", - "test:mcp:native-readers": "tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx src/modules/mcp/routing-network-native-parser.test.ts && tsx src/modules/mcp/routing-network-native-session.test.ts && tsx src/db/migration-schema-inventory.test.ts && tsx src/modules/mcp/routing-network-observation-assembly.test.ts && tsx src/modules/mcp/routing-network-configuration-bridge.test.ts", + "test:mcp:native-readers": "tsx src/modules/mcp/routing-network-verifier-model.test.ts && tsx src/modules/mcp/routing-network-native-parser.test.ts && tsx src/modules/mcp/routing-network-native-session.test.ts && tsx src/db/migration-schema-inventory.test.ts && tsx src/modules/mcp/routing-network-observation-assembly.test.ts && tsx src/modules/mcp/routing-network-configuration-bridge.test.ts && tsx --test src/services/routing-table-fib.test.ts src/services/routing-topology-observation.test.ts && node --test src/modules/mcp/routing-original-effects.test.mjs src/modules/mcp/routing-original-effects-caller.test.mjs src/modules/mcp/routing-original-effects-controller.test.mjs src/modules/mcp/routing-network-execution-origin.test.mjs", "test:mcp:native-readers:pg": "node scripts/test-mcp-postgres.mjs --routing-network-native --restore", "test:mcp:schema-inventory:pg": "node scripts/test-mcp-postgres.mjs --migration-schema-inventory --restore", "test:mcp:native-tls": "tsx src/modules/mcp/routing-network-native-tls.test.ts", diff --git a/backend/scripts/build-database-operator.mjs b/backend/scripts/build-database-operator.mjs index b1b2ce2d..b3ec2b43 100644 --- a/backend/scripts/build-database-operator.mjs +++ b/backend/scripts/build-database-operator.mjs @@ -10,6 +10,7 @@ import ts from 'typescript' export const OPERATOR_SOURCES=Object.freeze([ 'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts', 'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs', + 'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts', 'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts', 'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts', 'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts', diff --git a/backend/scripts/build-database-operator.test.mjs b/backend/scripts/build-database-operator.test.mjs index 59c30969..435cda00 100644 --- a/backend/scripts/build-database-operator.test.mjs +++ b/backend/scripts/build-database-operator.test.mjs @@ -19,7 +19,7 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r const packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8')) await mkdir(projectBackend,{recursive:true});await writeFile(path.join(projectBackend,'package.json'),JSON.stringify({type:'module',dependencies:packageJson.dependencies})) const first=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),second=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}) - assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,17);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,9) + assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,19);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12) const copied=new Set() async function copyDist(relative){ if(copied.has(relative))return;copied.add(relative) @@ -37,7 +37,7 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r for(const file of OPERATOR_SOURCES){const target=path.join(projectBackend,'scripts',file);await mkdir(path.dirname(target),{recursive:true});await copyFile(path.join(backend,'scripts',file),target)} const buildScript=path.join(projectBackend,'scripts','build-database-operator.mjs');await copyFile(fileURLToPath(new URL('./build-database-operator.mjs',import.meta.url)),buildScript) const built=spawnSync(process.execPath,[buildScript],{cwd:temporary,env:{...process.env,NODE_OPTIONS:''},encoding:'utf8',timeout:30000}) - assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,17) + assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,19) assert.deepEqual(JSON.parse(await readFile(path.join(output,'build-manifest.json'),'utf8')),first) const configFile=path.join(projectBackend,'dist','config.js'),originalConfig=await readFile(configFile,'utf8') for(const injected of ["import 'tsx';","import '../src/config.js';","import '../outside.js';"]){ @@ -59,10 +59,10 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r const modulesToImport=first.modules.map(item=>path.join(output,item.file)) const importer=path.join(temporary,'import-all.mjs');await writeFile(importer,`import {pathToFileURL} from 'node:url';for(const file of ${JSON.stringify(modulesToImport)})await import(pathToFileURL(file).href);console.log(JSON.stringify({imported:${modulesToImport.length},readyAuthorized:false}));`) const imported=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,importer],{cwd:temporary,env,encoding:'utf8',timeout:30000}) - assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,17);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0) + assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,19);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0) const refusals=[] - for(const [entry,event] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused'],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused']]){ - const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),'invalid'],{cwd:temporary,env,encoding:'utf8',timeout:30000}) + for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']]]){ + const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),...args],{cwd:temporary,env,encoding:'utf8',timeout:30000}) assert.equal(child.status,1,child.stderr);assert.equal(child.stdout,'');assert.equal(JSON.parse(child.stderr.trim()).event,event);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0);refusals.push(event) } const receipt={node:process.version,projection:temporary,modules:first.modules.length,compiledBackendModules:copied.size,buildCliExitCode:built.status,importedModules:modulesToImport.length,invalidArgumentsRefused:refusals,developmentDependenciesBlocked:denied,networkConnections:0,productionActivated:false,readyAuthorized:false} diff --git a/backend/scripts/database-maintenance-resolution.test.mts b/backend/scripts/database-maintenance-resolution.test.mts new file mode 100644 index 00000000..407ba3e1 --- /dev/null +++ b/backend/scripts/database-maintenance-resolution.test.mts @@ -0,0 +1,100 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {mkdtemp,mkdir,writeFile,readFile,readdir,rm,unlink,lstat} from 'node:fs/promises' +import {createHash,randomUUID} from 'node:crypto' +import {rootCertificates} from 'node:tls' +import {EventEmitter} from 'node:events' +import path from 'node:path' +import {fileURLToPath,pathToFileURL} from 'node:url' +import {runInNewContext} from 'node:vm' +import {createRequire} from 'node:module' +import * as strictReader from '../src/services/database-maintenance-startup.js' +import * as grammar from '../src/services/database-maintenance-resolution.js' +import * as manifest from '../src/db/migration-manifest.js' +import * as contract from './writer-profile/operator-contract.mjs' +const backend=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),require=createRequire(import.meta.url),ts=require('typescript'),hash=(value:Buffer|string)=>createHash('sha256').update(value).digest('hex') +async function own(work:(root:string)=>Promise){const temp=path.resolve(backend,'../tmp');await mkdir(temp,{recursive:true});const root=await mkdtemp(path.join(temp,'owned-resolution-command-'));try{await work(root)}finally{assert.equal(path.dirname(root),temp);assert(path.basename(root).startsWith('owned-resolution-command-'));await rm(root,{recursive:true,force:true})}} +async function model(root:string,localHost?:string){ + let owners=0,sql=0,releaseFailure=false,poolEndFailure=false,published=0,wrongOwner=false,controlSchema=false,observationFailure:Error|undefined + const capturedScopes:unknown[]=[] + const state=path.join(root,'state');await mkdir(state);await mkdir(path.join(state,'restore-vault'));await writeFile(path.join(state,'access-generation'),Buffer.alloc(32,7));const uncertain=randomUUID(),at=new Date().toISOString(),record=(value:string)=>JSON.stringify({operationId:uncertain,state:value,at})+'\n';await writeFile(path.join(state,'database-maintenance.jsonl'),record('intent')+record('unconfirmed')) + const source={systemIdentifier:'1234567890',databaseOid:'12345',databaseName:'managed',serverVersionNum:180004,inRecovery:false} + const schema={sha256:'a'.repeat(64),observedRaw:{sha256:'b'.repeat(64)},observedPhysical:{sha256:'c'.repeat(64)},sections:{relations:manifest.CURRENT_REQUIRED_TABLES.map(name=>({namespace:'public',name,kind:'r',persistence:'p',row_security:false,force_row_security:false})),columns:[{namespace:'public',relation:'schema_migrations',name:'id',dropped:false,type:'text'}]}} + class Pool {on(){};constructor(options:unknown){const ssl=(options as {ssl:false|{rejectUnauthorized:boolean}}).ssl;if(ssl===false)assert(['127.0.0.1','::1'].includes((options as {host:string}).host));else assert(ssl.rejectUnauthorized)}async connect(){owners++;const client=new EventEmitter();return Object.assign(client,{release:()=>{owners--;if(releaseFailure)throw Error('Synthetic release uncertainty')},query:async(text:string)=>{sql++;if(text.includes('pg_try_advisory_lock'))return {rows:[{locked:true}],command:'SELECT'};if(text.includes('pg_advisory_unlock'))return {rows:[{unlocked:true}],command:'SELECT'};if(text.includes('pg_namespace'))return {rows:controlSchema?[{nspname:'mmapp_control'}]:[],command:'SELECT'};if(text.includes('pg_control_system')){if(observationFailure)throw observationFailure;return {rows:[source],command:'SELECT'}};if(text.startsWith('SELECT id FROM public.schema_migrations'))return {rows:manifest.MIGRATION_MANIFEST.map(pin=>({id:pin.file.slice(0,-4)})),command:'SELECT'};return {rows:[],command:text.split(' ')[0]}}})}async end(){assert.equal(owners,0);if(poolEndFailure)throw Error('Synthetic pool retirement uncertainty')}} + const protectedSource=await readFile(path.join(backend,'scripts/writer-profile/operator-files.mjs'),'utf8'),protectedExports:Record={},actualUid=(await lstat(state)).uid + const protectedCode=ts.transpileModule(protectedSource,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText + runInNewContext(protectedCode,{exports:protectedExports,require,Buffer,process:{platform:'linux',getuid:()=>actualUid+1},Object,Error}) + const protectedDirectory=async(directory:string)=>{assert(path.resolve(directory).startsWith(root+path.sep)||path.resolve(directory)===root);if(wrongOwner)await (protectedExports.protectedDirectory as (path:string)=>Promise)(directory);await readdir(directory)} + // Explicit MODELED protection adapter: native POSIX mode/ownership and hardlink + // fsync publication remain separate Linux acceptance, never a Windows claim. + const files={sha:hash,protectedDirectory,readProtected:async(file:string,maximum:number,pin?:string)=>{assert(file.startsWith(root+path.sep));const raw=await readFile(file);assert(raw.length>0&&raw.length<=maximum);if(pin)assert.equal(hash(raw),pin);return raw},writeFile:async(file:string,raw:Buffer)=>{assert(file.startsWith(root+path.sep));await writeFile(file,raw,{flag:'wx'});published++},withJournalLease:async(directory:string,work:()=>Promise)=>{const lock=path.join(directory,'publisher-lease.lock');await writeFile(lock,'owned',{flag:'wx'});try{return await work()}finally{await unlink(lock)}}} + const readRecords=async(directory:string)=>{const records=path.join(directory,'maintenance-acceptances');let names:string[];try{names=await readdir(records)}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return [];throw error};assert(names.every(name=>/^[a-f0-9-]{36}\.json$/.test(name)));return Promise.all(names.sort().map(name=>readFile(path.join(records,name))))} + const moduleFile=path.join(backend,'scripts/writer-profile/maintenance-resolution.mts'),sourceText=(await readFile(moduleFile,'utf8')).replaceAll('import.meta.url',JSON.stringify(pathToFileURL(moduleFile).href)),exports:Record={} + const dependencies:Record={'./operator-files.mjs':files,'./operator-contract.mjs':contract,'../../src/services/database-maintenance-startup.js':{...strictReader,readMaintenanceAcceptanceRecords:readRecords},'../../src/services/database-maintenance-resolution.js':grammar,'../../src/db/migration-schema-inventory.js':{captureMigrationSchema:async(_client:unknown,options:{scope:unknown})=>{capturedScopes.push(options.scope);return schema}},'../../src/db/migration-manifest.js':manifest,pg:{Pool}} + const code=ts.transpileModule(sourceText,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText + runInNewContext(code,{exports,require:(name:string)=>name in dependencies?dependencies[name]:require(name),Buffer,URL,process:{env:{}},Date,Object,Map,Set,Error,console}) + const command=exports.maintenanceMain as (args:string[])=>Promise> + const tlsTransport={format:'database-maintenance-transport-v1',connection:{host:'db.example.invalid',port:5432,user:'existing-maintenance-user',password:'synthetic-only',database:'managed'},tls:{serverName:'db.example.invalid',spkiSha256:'f'.repeat(64)}},localTransport=(host:string)=>({format:'database-maintenance-transport-v1',mode:'local-loopback',connection:{...tlsTransport.connection,host}}) + const configs={transport:localHost===undefined?tlsTransport:localTransport(localHost),'runtime-target':{format:'database-maintenance-runtime-target-v1',databaseUrl:'postgresql://db.example.invalid:5432/managed?sslmode=require'}} + const args=['--state-dir',state] + for(const [name,value]of Object.entries(configs)){const raw=Buffer.from(JSON.stringify(value)),file=path.join(root,name+'.json');await writeFile(file,raw);args.push('--'+name,file,'--'+name+'-sha256',hash(raw))} + const ca=Buffer.from(rootCertificates[0]),caFile=path.join(root,'ca.pem');await writeFile(caFile,ca);if(localHost===undefined)args.push('--ca',caFile,'--ca-sha256',hash(ca)) + const output=path.join(root,'inspection.json') + async function inspect(destination=output){return command(['maintenance-inspect',...args,'--output',destination])} + async function approve(change?:(value:Record)=>void){const raw=await readFile(output),inspection=JSON.parse(raw.toString()),approval={format:'database-maintenance-current-state-approval-v1',resolutionId:randomUUID(),decision:'accept-observed-current-state',actorId:'host-human-reviewer',inspectionSha256:hash(raw),issuedAt:new Date().toISOString(),expiresAt:new Date(Date.now()+60000).toISOString(),managedBackendStopped:true,historicOutcomesRemainUnknown:true,automaticRestore:false};assert(inspection.createdAt<=approval.issuedAt);change?.(approval);const bytes=Buffer.from(JSON.stringify(approval)),file=path.join(root,'approval.json');await writeFile(file,bytes);return ['maintenance-resolve',...args,'--inspection',output,'--inspection-sha256',hash(raw),'--approval',file,'--approval-sha256',hash(bytes)]} + const toLocal=async(host='127.0.0.1')=>{const raw=Buffer.from(JSON.stringify(localTransport(host)));await writeFile(path.join(root,'transport.json'),raw);args[args.indexOf('--transport-sha256')+1]=hash(raw);for(const flag of ['--ca','--ca-sha256']){const at=args.indexOf(flag);if(at>=0)args.splice(at,2)}} + const configuration=exports.maintenancePoolConfiguration as (value:unknown,ca?:Buffer,pin?:string)=>{options:Record;transportBinding:{mode:string;endpointSha256:string}} + return {command,inspect,approve,toLocal,configuration,readRecords,state,output,sql:()=>sql,published:()=>published,failRelease:()=>{releaseFailure=true},failEnd:()=>{poolEndFailure=true},driftSchema:()=>{schema.sha256='0'.repeat(64)},driftTarget:()=>{source.databaseOid='54321'},wrongOwner:()=>{wrongOwner=true},withControlSchema:()=>{controlSchema=true},scopes:()=>capturedScopes,failObservation:(error:Error)=>{observationFailure=error}} +} +test('actual operator commands inspect then separately approved resolve; old uncertain bytes never rewritten',()=>own(async root=>{ + const fixture=await model(root),journal=path.join(fixture.state,'database-maintenance.jsonl'),before=await readFile(journal),inspected=await fixture.inspect();assert.equal(inspected.humanApprovalRequired,true);assert.equal((await fixture.readRecords(fixture.state)).length,0) + const result=await fixture.command(await fixture.approve());assert.equal(result.outcome,'committed');assert.equal(result.historicOutcomesChanged,false);assert.equal(result.archiveDataEquivalenceProved,false);assert.equal(result.automaticRestore,false) + assert.deepEqual(await readFile(journal),before);const records=await fixture.readRecords(fixture.state),facts=await strictReader.readMaintenanceRecoveryFacts(fixture.state);assert.equal(records.length,1);const accepted=grammar.evaluateMaintenanceAcceptances(records,facts.journal,facts.vault.evidence,grammar.runtimeDatabaseTargetSha256('postgresql://db.example.invalid:5432/managed?sslmode=require'));assert.equal(accepted.count,1) + fixture.driftSchema();const afterStatus=await fixture.inspect(path.join(root,'after-inspection.json'));assert.equal(afterStatus.state,'historically-accepted');assert.equal(afterStatus.humanApprovalRequired,false);assert.equal(afterStatus.historicOutcomesChanged,false) + await assert.rejects(fixture.command(await fixture.approve()),/No new unresolved|already|assert|Assertion/i) +})) +for(const type of ['expired','not-stopped','wrong-inspection'])test('actual resolve refuses '+type+' approval before connecting SQL',()=>own(async root=>{ + const fixture=await model(root);await fixture.inspect();const args=await fixture.approve(value=>{if(type==='expired')value.expiresAt=new Date(Date.now()-1).toISOString();if(type==='not-stopped')value.managedBackendStopped=false;if(type==='wrong-inspection')value.inspectionSha256='0'.repeat(64)}),before=fixture.sql();await assert.rejects(fixture.command(args));assert.equal(fixture.sql(),before);assert.equal((await fixture.readRecords(fixture.state)).length,0) +})) +for(const type of ['release','pool-end','schema-drift','target-drift','external-drift'])test('actual resolve refuses '+type+' before acceptance publication',()=>own(async root=>{ + const fixture=await model(root);await fixture.inspect();const args=await fixture.approve();if(type==='release')fixture.failRelease();if(type==='pool-end')fixture.failEnd() + if(type==='schema-drift')fixture.driftSchema();if(type==='target-drift')fixture.driftTarget() + if(type==='external-drift')await appendFileSafe(path.join(fixture.state,'database-maintenance.jsonl')) + await assert.rejects(fixture.command(args));assert.equal((await fixture.readRecords(fixture.state)).length,0) +})) +async function appendFileSafe(file:string){await writeFile(file,await readFile(file,'utf8')+JSON.stringify({operationId:randomUUID(),state:'intent',at:new Date().toISOString()})+'\n')} +test('existing shipped main dispatches both commands before writer-profile role/source flags',async()=>{ + const source=await readFile(path.join(backend,'scripts/database-writer-profile-operator.mts'),'utf8');assert(source.indexOf("['maintenance-inspect','maintenance-resolve']")own(async root=>{ + const fixture=await model(root);fixture.wrongOwner();await assert.rejects(fixture.inspect());assert.equal(fixture.sql(),0);assert.equal(fixture.published(),0);assert.equal((await fixture.readRecords(fixture.state)).length,0) +})) + +test('explicit local-loopback supports both IP families and binds selected mode in actual inspect/resolve',async()=>{ + for(const host of ['127.0.0.1','::1'])await own(async root=>{const fixture=await model(root,host);await fixture.inspect();const result=await fixture.command(await fixture.approve());assert.equal(result.outcome,'committed');const records=await fixture.readRecords(fixture.state);assert.equal(JSON.parse(records[0].toString()).knownCurrent.transport.mode,'local-loopback')}) +}) +test('plaintext remote/DNS/unspecified/mapped hosts refuse before SQL and never fall back',async()=>{ + for(const host of ['db.example.invalid','localhost','0.0.0.0','192.0.2.1','[::1]','::ffff:127.0.0.1'])await own(async root=>{const fixture=await model(root,host);await assert.rejects(fixture.inspect());assert.equal(fixture.sql(),0);assert.equal(fixture.published(),0)}) +}) +test('an approval for TLS observation cannot resolve through a changed local-loopback transport',()=>own(async root=>{ + const fixture=await model(root);await fixture.inspect();await fixture.toLocal();await assert.rejects(fixture.command(await fixture.approve()));assert.equal((await fixture.readRecords(fixture.state)).length,0) +})) +test('local mode explicitly disables SSL/environment substitution; TLS remains verified default without fallback',()=>own(async root=>{ + const fixture=await model(root),ConnectionParameters=require('pg/lib/connection-parameters'),raw={format:'database-maintenance-transport-v1',mode:'local-loopback',connection:{host:'127.0.0.1',port:5432,user:'existing-user',password:'synthetic-only',database:'managed'}} + const previous={PGHOST:process.env.PGHOST,PGSSLMODE:process.env.PGSSLMODE,PGOPTIONS:process.env.PGOPTIONS};process.env.PGHOST='remote.invalid';process.env.PGSSLMODE='require';process.env.PGOPTIONS='-c role=foreign' + try{const config=fixture.configuration(raw),actual=new ConnectionParameters(config.options);assert.equal(actual.host,'127.0.0.1');assert.equal(actual.ssl,false);assert.equal(actual.options,contract.STARTUP_OPTIONS);assert.equal(config.transportBinding.mode,'local-loopback')} + finally{for(const [key,value]of Object.entries(previous)){if(value===undefined)delete process.env[key];else process.env[key]=value}} + const ca=Buffer.from(rootCertificates[0]),tls={format:'database-maintenance-transport-v1',connection:{...raw.connection,host:'db.example.invalid'},tls:{serverName:'db.example.invalid',spkiSha256:'f'.repeat(64)}} + assert.throws(()=>fixture.configuration(tls));const secure=fixture.configuration(tls,ca,hash(ca));assert.equal(secure.transportBinding.mode,'tls');assert.equal((secure.options.ssl as {rejectUnauthorized:boolean}).rejectUnauthorized,true) + assert.throws(()=>fixture.configuration({...raw,mode:'plaintext'}));assert.throws(()=>fixture.configuration({...tls,mode:null},ca,hash(ca)));assert.throws(()=>fixture.configuration(raw,ca,hash(ca))) +})) + +// A normal installation has no optional writer-profile schema. Observation must +// select its exact namespace set without requiring installation of that system. +test('maintenance observes with and without the optional control namespace',async()=>{ + for(const present of [false,true])await own(async root=>{const fixture=await model(root);if(present)fixture.withControlSchema();await fixture.inspect();assert.equal(JSON.stringify(fixture.scopes()),JSON.stringify([{applicationNamespaces:['public'],excludedControlNamespaces:present?['mmapp_control']:[]}]))}) +}) +test('original observation failure survives confirmed rollback/unlock/release',()=>own(async root=>{ + const fixture=await model(root),failure=Error('Synthetic original observation failure');fixture.failObservation(failure);await assert.rejects(fixture.inspect(),error=>error===failure);assert.equal(fixture.published(),0);assert.equal((await fixture.readRecords(fixture.state)).length,0) +})) diff --git a/backend/scripts/database-writer-profile-operator.mts b/backend/scripts/database-writer-profile-operator.mts index 7cd189a3..5f83673a 100644 --- a/backend/scripts/database-writer-profile-operator.mts +++ b/backend/scripts/database-writer-profile-operator.mts @@ -9,6 +9,7 @@ import {deploymentRequest} from './database-writer-bootstrap-operator.mjs' const hex=(v:unknown)=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v) const parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b)) export async function main(args:string[]){ + if(['maintenance-inspect','maintenance-resolve'].includes(args[0]))return (await import('./writer-profile/maintenance-resolution.mjs')).maintenanceMain(args) const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'] assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length) assertJsDriverEnvironment(process.env) @@ -38,7 +39,12 @@ export async function main(args:string[]){ assertJsDriverEnvironment(process.env) assert.equal(approvedDefinition(definition),pinned.definitionSha256) const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source}) - const verifier=createDatabaseSourceBindingVerifier({registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256}) + const sourcePin={registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256} + // Preflight is the read-only lifecycle entry for protected registry evidence. + // A refused protection check never falls back to the signature-only verifier. + const verifier=args[0]==='preflight' + ?(await import('./writer-profile/protected-source-registry.mjs')).createProtectedDatabaseSourceBindingVerifier(sourcePin) + :createDatabaseSourceBindingVerifier(sourcePin) const publisher=createConnectedPublisher(installation,(s:Parameters[0])=>collectSource44Catalogue(s,installation),verifier,pinned) const pool=new pg.Pool(options) // A pool-level async error never becomes raw stdout or a retry signal. @@ -47,7 +53,7 @@ export async function main(args:string[]){ try{result=args[0]==='preflight'?await publisher.preflightCurrentProfile(pool,{deadlineAt:Date.now()+120000}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt:Date.now()+120000}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt:Date.now()+120000})} catch(error){primary=error;failed=true} try{await pool.end()}catch(error){primary??=error;failed=true} - if(args[0]==='preflight'&&result?.state==='matched'&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} + if(args[0]==='preflight'&&result?.state==='matched'&&result.platformProtectionVerified===true&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter, cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false} @@ -55,5 +61,5 @@ export async function main(args:string[]){ } if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){ try{const result=await main(process.argv.slice(2));console.log(JSON.stringify(result));if(result.cleanupUnconfirmed||result.journalUnconfirmed)process.exitCode=2} - catch{console.error(JSON.stringify({event:'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1} + catch{console.error(JSON.stringify({event:['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1} } diff --git a/backend/scripts/database-writer-profile-preflight.test.mts b/backend/scripts/database-writer-profile-preflight.test.mts index d63432ba..624a1207 100644 --- a/backend/scripts/database-writer-profile-preflight.test.mts +++ b/backend/scripts/database-writer-profile-preflight.test.mts @@ -11,9 +11,9 @@ import {readCurrentApplicationWriterProfile} from '../src/db/application-writer- import {syntheticFixture,stagedModelClient} from '../src/db/application-writer-profile-registry-read-model.test.ts' const require=createRequire(new URL('../package.json',import.meta.url)),ts=require('typescript'),pg=require('pg') const candidate=await fs.readFile(new URL('./writer-profile/publisher.mjs',import.meta.url),'utf8') -interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean} -interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise,sourceVerifier:{verify:()=>Promise},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise}} -interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;writerAuthorized:boolean;readyAuthorized:boolean}>} +interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;platformProtectionVerified:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean} +interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise}} +interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean}>} function compile(source:string,modules:Record):T{ const exports:Record={};const compiled=ts.transpileModule(source.split('\nif(process.argv[1]')[0].replaceAll('import.meta.url',JSON.stringify('file:///candidate-not-cli.mts')),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText runInNewContext(compiled,{exports,require:(name:string)=>{assert(name in modules,'Unapproved import '+name);return modules[name]},Buffer,URL,TextDecoder,Date,setTimeout,clearTimeout,Object,Error,AggregateError,Promise,Map,Set,process:{env:{},argv:[]}}, {filename:'candidate.mjs'}) @@ -56,17 +56,23 @@ function fixture(fault=''){ '../../src/db/application-writer-profile-format.js':format, './context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'}, } - const api=compile(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1}},approval) + const api=compile(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval) return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),queries,events,f} } test('current44 preflight authenticates signed current and actual component inputs on one read-only lease',async()=>{ const f=fixture(),result=await f.run();assert.equal(result.state,'matched');assert.equal(result.headSha256,f.f.state.current[0].head_sha256) assert.equal(result.sourceMatched,true);assert.equal(result.rolesMatched,true);assert.equal(result.catalogueMatched,true) + assert.equal(result.platformProtectionVerified,true) assert.equal(result.writerAuthorized,false);assert.equal(result.generationAuthenticated,false);assert.equal(result.readyAuthorized,false) assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1);assert(f.queries.includes('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'));assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1) assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.events.at(-1),'release:false') assert.deepEqual(f.events.slice(0,6),['capture-source','verify-source','roles','catalogue','verify-source','fresh-source']) }) +for(const fault of ['initial-unprotected','terminal-unprotected'])test('preflight cannot promote '+fault+' into protected evidence',async()=>{ + const f=fixture(fault),result=await f.run();assert.equal(result.platformProtectionVerified,false) + assert.equal(result.writerAuthorized,false);assert.equal(result.readyAuthorized,false) + assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q))) +}) for(const fault of ['tamper','empty','expired','signed-source','signed-role','signed-catalogue','signed-definition','signed-manifest','role-structure','catalogue-eof','source-verifier','late-source','late-actor','late-expiry','late-clock','rollback-ack'])test('preflight refuses '+fault+' without any write or replay',async()=>{ const f=fixture(fault);await assert.rejects(f.run());assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1) }) @@ -74,23 +80,28 @@ test('existing operator main dispatches preflight only after protected input val const cli=await fs.readFile(new URL('./database-writer-profile-operator.mts',import.meta.url),'utf8'),f=syntheticFixture('1'),reads:string[]=[],events:string[]=[] const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64)}} const files:Record={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture'} - let failEnd=false,asyncError=false + let failEnd=false,asyncError=false,protectedRefused=false const retainedPools:Pool[]=[] class Pool extends EventEmitter{constructor(){super();retainedPools.push(this)}async end(){events.push('pool-end');if(failEnd)throw Error('Pool cleanup unconfirmed')}} - const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000} + const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000,platformProtectionVerified:true} + const protectedVerifier={verify:async()=>({platformProtectionVerified:true})} const modules={ 'node:assert/strict':assert,'node:path':require('node:path'),'node:url':require('node:url'),'node:crypto':require('node:crypto'), './writer-profile/operator-files.mjs':{readProtected:async(file:string)=>{reads.push(file);assert(file in files);return Buffer.from(JSON.stringify(files[file]))},protectedDirectory:async()=>{},sha:format.sha}, './writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:()=>{throw Error('No expired reconciliation bypass')},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}}, - './database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>({})}, - pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:()=>({preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}})}, + './database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>{throw Error('Preflight must never fall back to unprotected source verification')}}, + './writer-profile/protected-source-registry.mjs':{createProtectedDatabaseSourceBindingVerifier:(pin:{registryDirectory:string;currentHeadSha256:string})=>{events.push('protected-source');assert.equal(pin.registryDirectory,'source-registry');assert.equal(pin.currentHeadSha256,request.sourceHeadSha256);if(protectedRefused)throw Error('Protected source refused');return protectedVerifier}}, + pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{assert.equal(verifier,protectedVerifier);return {preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}}}}, './writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=> 'b'.repeat(64)}, } const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'],args=['preflight',...flags.flatMap(flag=>['--'+flag,flag.endsWith('sha256')?'a'.repeat(64):flag])] const output=await compile(cli,modules).main(args) - assert.equal(output.event,'application-writer-profile-preflight');assert.equal(output.outcome,'matched');assert.equal(output.writerAuthorized,false);assert.equal(output.readyAuthorized,false) - assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca']);assert.deepEqual(events,['approval','transport','preflight','pool-end']) + assert.equal(output.event,'application-writer-profile-preflight');assert.equal(output.outcome,'matched');assert.equal(output.platformProtectionVerified,true);assert.equal(output.writerAuthorized,false);assert.equal(output.readyAuthorized,false) + assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca']);assert.deepEqual(events,['approval','transport','protected-source','preflight','pool-end']) failEnd=true;await assert.rejects(compile(cli,modules).main(args),/Pool cleanup unconfirmed/) failEnd=false;asyncError=true;await assert.rejects(compile(cli,modules).main(args),/Publisher terminal unconfirmed/) + asyncError=false;result.platformProtectionVerified=false;await assert.rejects(compile(cli,modules).main(args),/Publisher terminal unconfirmed/) + result.platformProtectionVerified=true;protectedRefused=true;const before=retainedPools.length + await assert.rejects(compile(cli,modules).main(args),/Protected source refused/);assert.equal(retainedPools.length,before,'Protection failure must not open a Pool or fall back') }) diff --git a/backend/scripts/writer-profile/maintenance-resolution.mts b/backend/scripts/writer-profile/maintenance-resolution.mts new file mode 100644 index 00000000..b08935d6 --- /dev/null +++ b/backend/scripts/writer-profile/maintenance-resolution.mts @@ -0,0 +1,129 @@ +/** Explicit host-operator inspection/acceptance. No restore, service restart, role + * enrollment or source/READY issuer. Populated data is accepted, not certified. */ +import assert from 'node:assert/strict' +import path from 'node:path' +import {fileURLToPath} from 'node:url' +import {mkdir,readdir} from 'node:fs/promises' +import {readProtected,protectedDirectory,writeFile,withJournalLease,sha} from './operator-files.mjs' +import {poolOptions,STARTUP_OPTIONS,assertJsDriverEnvironment} from './operator-contract.mjs' +import {readMaintenanceRecoveryFacts,readMaintenanceAcceptanceRecords} from '../../src/services/database-maintenance-startup.js' +import {parseMaintenanceJSON,runtimeDatabaseTargetSha256,evaluateMaintenanceAcceptances,validateMaintenanceKnownCurrent,validateMaintenanceAcceptance,MAINTENANCE_ACCEPTANCE_FORMAT,type MaintenanceKnownCurrent} from '../../src/services/database-maintenance-resolution.js' +import {captureMigrationSchema} from '../../src/db/migration-schema-inventory.js' +import {MIGRATION_MANIFEST,CURRENT_REQUIRED_TABLES,migrationManifestDigest,inventoryMigrationDirectory} from '../../src/db/migration-manifest.js' +import type {Pool,PoolClient} from 'pg' +const hex=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v) +function object(v:unknown):asserts v is Record{assert(v&&typeof v==='object'&&!Array.isArray(v))} +function exact(v:unknown,keys:string[]):asserts v is Record{object(v);assert.equal(Object.keys(v).sort().join('\0'),keys.sort().join('\0'))} +const stamp=(v:unknown):v is string=>typeof v==='string'&&Number.isFinite(Date.parse(v))&&new Date(v).toISOString()===v +const stable=(v:unknown)=>JSON.stringify(v) +const flags=['state-dir','transport','transport-sha256','runtime-target','runtime-target-sha256'] +const validityMs=900000 +/** Explicit local-container transport never falls back from TLS or environment. + * Caller launch review supplies --network container:; + * this module has no Docker socket/API and claims only loopback + physical SQL proof. */ +export function maintenancePoolConfiguration(value:unknown,ca?:Buffer,caSha256?:string){ + object(value);const mode=value.mode===undefined?'tls':value.mode;assert(mode==='tls'||mode==='local-loopback') + exact(value,mode==='local-loopback'?['format','mode','connection']:value.mode===undefined?['format','connection','tls']:['format','mode','connection','tls']) + assert.equal(value.format,'database-maintenance-transport-v1');exact(value.connection,['host','port','user','password','database']);const connection=value.connection + assert(typeof connection.host==='string'&&typeof connection.user==='string'&&/^[^\x00-\x1f\x7f]{1,63}$/.test(connection.user)&&typeof connection.password==='string'&&connection.password.length>0&&connection.password.length<=4096&&!connection.password.includes('\0')&&typeof connection.database==='string'&&/^[^\x00-\x1f\x7f]{1,63}$/.test(connection.database)&&typeof connection.port==='number'&&Number.isSafeInteger(connection.port)&&Number(connection.port)>0&&Number(connection.port)<=65535) + let options,identity:unknown + if(mode==='tls'){ + exact(value.tls,['serverName','spkiSha256']);assert(typeof value.tls.serverName==='string'&&/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(value.tls.serverName)&&hex(value.tls.spkiSha256));assert(ca&&hex(caSha256)) + options=poolOptions(connection,{roles:{publisher:{name:connection.user}},source:{databaseName:connection.database},tls:{...value.tls,caSha256}},ca) + identity={mode,host:connection.host,port:connection.port,database:connection.database,tls:{...value.tls,caSha256}} + }else{ + assert(connection.host==='127.0.0.1'||connection.host==='::1','Plaintext maintenance requires exact loopback and reviewed PostgreSQL container namespace');assert(ca===undefined&&caSha256===undefined) + options={host:connection.host,port:connection.port,user:connection.user,password:connection.password,database:connection.database,max:1,connectionTimeoutMillis:5000,idleTimeoutMillis:5000,statement_timeout:10000,lock_timeout:5000,options:STARTUP_OPTIONS,application_name:'mmapp-maintenance-acceptance',client_encoding:'UTF8',ssl:false} + identity={mode,host:connection.host,port:connection.port,database:connection.database} + } + return {options,transportBinding:{mode,endpointSha256:sha(stable(identity))} as MaintenanceKnownCurrent['transport']} +} +export function maintenanceApproval(value:unknown,inspectionSha256:string,inspectionCreatedAt:string){ + exact(value,['format','resolutionId','decision','actorId','inspectionSha256','issuedAt','expiresAt','managedBackendStopped','historicOutcomesRemainUnknown','automaticRestore']) + assert(value.format==='database-maintenance-current-state-approval-v1'&&value.decision==='accept-observed-current-state'&&typeof value.resolutionId==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value.resolutionId)) + assert(typeof value.actorId==='string'&&/^[^\x00-\x1f\x7f]{1,160}$/.test(value.actorId)&&value.inspectionSha256===inspectionSha256&&stamp(value.issuedAt)&&stamp(value.expiresAt)) + const now=Date.now();assert(Date.parse(value.issuedAt)>=Date.parse(inspectionCreatedAt)&&Date.parse(value.issuedAt)<=now&&now,transport:MaintenanceKnownCurrent['transport']){ + let client:PoolClient|undefined,locked=false,begun=false,bad=false,faulted=false + const fault=()=>{faulted=true},live=()=>{assert(!faulted)} + try{ + client=await pool.connect();client.on('error',fault);client.on('end',fault) + const query=async(text:string,values:unknown[]=[])=>{live();const result=await client!.query(text,values);live();return result} + await query("SET statement_timeout='10s'");await query("SET lock_timeout='5s'") + const lease=await query('SELECT pg_catalog.pg_try_advisory_lock(8723110::bigint) AS locked');assert(lease.rows.length===1&&lease.rows[0].locked===true);locked=true + await query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');begun=true + for(const sql of ["SET LOCAL search_path=pg_catalog","SET LOCAL TimeZone='UTC'","SET LOCAL DateStyle='ISO, YMD'","SET LOCAL standard_conforming_strings=on","SET LOCAL statement_timeout='10s'"])await query(sql) + const identity=await query(`SELECT c.system_identifier::text AS "systemIdentifier",d.oid::text AS "databaseOid",d.datname AS "databaseName",pg_catalog.current_setting('server_version_num')::int AS "serverVersionNum",pg_catalog.pg_is_in_recovery() AS "inRecovery" FROM pg_catalog.pg_database d CROSS JOIN pg_catalog.pg_control_system() c WHERE d.datname=pg_catalog.current_database()`) + assert.equal(identity.rows.length,1) + // The optional writer-profile control schema is not installed by ordinary + // application migrations. Observe its presence in this same transaction; + // detailed capture still refuses every other unexpected namespace. + const control=await query("SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname='mmapp_control'") + assert(control.rows.length<=1&&control.rows.every(row=>row.nspname==='mmapp_control')) + const schema=await captureMigrationSchema({query:async(text,values)=>query(text,values)},{scope:{applicationNamespaces:['public'],excludedControlNamespaces:control.rows.length?['mmapp_control']:[]},deadlineAt:Date.now()+60000}) + const marker=schema.sections.relations.find(row=>row.namespace==='public'&&row.name==='schema_migrations'),column=schema.sections.columns.find(row=>row.namespace==='public'&&row.relation==='schema_migrations'&&row.name==='id'&&row.dropped===false) + assert(marker?.kind==='r'&&marker.persistence==='p'&&marker.row_security===false&&marker.force_row_security===false&&column?.type==='text') + for(const table of CURRENT_REQUIRED_TABLES)assert(schema.sections.relations.some(row=>row.namespace==='public'&&row.name===table&&['r','p'].includes(String(row.kind))&&row.row_security===false&&row.force_row_security===false)) + const markers=(await query('SELECT id FROM public.schema_migrations ORDER BY id LIMIT $1',[MIGRATION_MANIFEST.length+1])).rows + assert.equal(stable(markers),stable(MIGRATION_MANIFEST.map(entry=>({id:entry.file.slice(0,-4)})))) + const knownCurrent={transport,source:identity.rows[0],schemaSha256:schema.sha256,rawSchemaSha256:schema.observedRaw.sha256,physicalSchemaSha256:schema.observedPhysical.sha256,manifestSha256:migrationManifestDigest(),markersSha256:sha(stable(markers))} + validateMaintenanceKnownCurrent(knownCurrent) + const end=await query('ROLLBACK');assert.equal(end.command,'ROLLBACK');begun=false + const unlock=await query('SELECT pg_catalog.pg_advisory_unlock(8723110::bigint) AS unlocked');assert(unlock.rows.length===1&&unlock.rows[0].unlocked===true);locked=false + return {knownCurrent,schema} + }catch(error){bad=true;throw error}finally{ + if(client){let cleanupFailed=false + try{if(begun&&!faulted){const end=await client.query('ROLLBACK');assert.equal(end.command,'ROLLBACK')}if(locked&&!faulted){const end=await client.query('SELECT pg_catalog.pg_advisory_unlock(8723110::bigint) AS unlocked');assert(end.rows[0]?.unlocked===true)}}catch{cleanupFailed=true} + try{client.off('error',fault);client.off('end',fault)}catch{cleanupFailed=true} + try{client.release(bad||faulted||cleanupFailed)}catch{cleanupFailed=true} + assert(!cleanupFailed&&!faulted,'Read-only maintenance owner cleanup unconfirmed') + } + } +} +async function snapshot(stateDirectory:string,target:string,records:readonly Buffer[]){ + const state=await readMaintenanceRecoveryFacts(stateDirectory);assert(state.kind==='journal'&&state.generation) + const accepted=evaluateMaintenanceAcceptances(records,state.journal,state.vault.evidence,target) + const unresolvedOperations=state.operations.filter(op=>op.state!=='applied'),vaultCoverage=state.vault.evidence.filter(marker=>state.vault.unresolvedFiles.includes(marker.file)) + const newUnresolved=unresolvedOperations.some(op=>!accepted.acceptedCoreIds.has(op.operationId))||state.vault.unresolvedFiles.some(file=>!accepted.acceptedVaultFiles.has(file)) + return {newUnresolved,external:{journalCoverage:{prefixBytes:state.journal.length,sha256:sha(state.journal),unresolvedOperations},vaultCoverage,generationObservedSha256:sha(state.generation),previousResolutionSha256:accepted.headSha256}} +} +export async function maintenanceMain(args:string[]){ + assertJsDriverEnvironment(process.env);const resolve=args[0]==='maintenance-resolve';assert(resolve||args[0]==='maintenance-inspect') + const required=[...flags,...(resolve?['inspection','inspection-sha256','approval','approval-sha256']:['output'])],names=[...required,'ca','ca-sha256'],values=new Map() + assert(args.length===1+required.length*2||args.length===1+(required.length+2)*2) + for(let i=1;ivalues.get(name)! + for(const name of [...values.keys()].filter(name=>name.endsWith('sha256')))assert(hex(get(name))) + const stateDirectory=get('state-dir');await protectedDirectory(stateDirectory) + const transport=parseMaintenanceJSON(await readProtected(get('transport'),16384,get('transport-sha256')));object(transport);const mode=transport.mode===undefined?'tls':transport.mode;assert(mode==='tls'||mode==='local-loopback');assert(values.has('ca')===(mode==='tls')) + const ca=mode==='tls'?await readProtected(get('ca'),65536,get('ca-sha256')):undefined + const runtime=parseMaintenanceJSON(await readProtected(get('runtime-target'),16384,get('runtime-target-sha256')));exact(runtime,['format','databaseUrl']);assert(runtime.format==='database-maintenance-runtime-target-v1'&&typeof runtime.databaseUrl==='string');const runtimeUrl=new URL(runtime.databaseUrl);assert(!runtimeUrl.username&&!runtimeUrl.password) + const target=runtimeDatabaseTargetSha256(runtime.databaseUrl),{options,transportBinding}=maintenancePoolConfiguration(transport,ca,mode==='tls'?get('ca-sha256'):undefined) + exact(transport.connection,['host','port','user','password','database']);assert.equal(decodeURIComponent(runtimeUrl.pathname.slice(1)),transport.connection.database) + const records=await readMaintenanceAcceptanceRecords(stateDirectory);if(resolve)assert(records.length<64,'Maintenance acceptance history bound reached');const currentState=await snapshot(stateDirectory,target,records),before=currentState.external;if(resolve)assert(currentState.newUnresolved,'No new unresolved history to accept') + await inventoryMigrationDirectory(path.join(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))),'drizzle')) + let inspection:Record|undefined,approved:ReturnType|undefined + if(resolve){inspection=parseMaintenanceJSON(await readProtected(get('inspection'),67108864,get('inspection-sha256'))) as Record;exact(inspection,['format','createdAt','expiresAt','runtimeTargetSha256','acceptanceRequired','external','knownCurrent','schema']);assert(inspection.format==='database-maintenance-current-state-inspection-v1'&&stamp(inspection.createdAt)&&stamp(inspection.expiresAt)&&Date.parse(inspection.createdAt)<=Date.now()&&Date.now(){const pool=new pg.Pool(options);let failed=false;pool.on('error',()=>{failed=true});try{const result=await observeMaintenanceCurrent(pool,transportBinding);await pool.end();assert(!failed);return result}catch(error){try{await pool.end()}catch{/* preserve refusal */}throw error}} + if(!resolve){const result=await runProof();assert.equal(stable((await snapshot(stateDirectory,target,await readMaintenanceAcceptanceRecords(stateDirectory))).external),stable(before));const nowMs=Date.now(),now=new Date(nowMs).toISOString(),out={format:'database-maintenance-current-state-inspection-v1',createdAt:now,expiresAt:new Date(nowMs+validityMs).toISOString(),runtimeTargetSha256:target,acceptanceRequired:currentState.newUnresolved,external:before,...result};const raw=Buffer.from(stable(out));assert(raw.length<=67108864);await writeFile(get('output'),raw,{flag:'wx',mode:0o600});return {event:'database-maintenance-inspection',inspectionSha256:sha(raw),unresolvedCoreOperations:before.journalCoverage.unresolvedOperations.length,unresolvedVaultFiles:before.vaultCoverage.length,state:currentState.newUnresolved?'inspection-required':before.previousResolutionSha256?'historically-accepted':'no-unresolved-history',historicalAcceptanceHeadSha256:before.previousResolutionSha256,humanApprovalRequired:currentState.newUnresolved,knownCurrentObserved:true,archiveDataEquivalenceProved:false,historicOutcomesChanged:false,automaticRestore:false}} + const resolutionDirectory=path.join(stateDirectory,'maintenance-acceptances');await mkdir(resolutionDirectory,{recursive:true,mode:0o700});await protectedDirectory(resolutionDirectory) + return withJournalLease(resolutionDirectory,async()=>{ + // Existing immutable records are reread while our proven O_EXCL lease is held. + // Runtime reader refuses this lease; never infer stale ownership from PID/time. + const expected=records.map(raw=>{const v=parseMaintenanceJSON(raw) as {resolutionId:string};return v.resolutionId+'.json'}).sort() + assert.equal((await readdir(resolutionDirectory)).sort().join('\0'),[...expected,'publisher-lease.lock'].sort().join('\0')) + for(let i=0;itypeof value==='string'&&/^[a-f0-9]{64}$/.test(value) +function fence(options:DatabaseSourceObservationOptions){ + assert(Number.isSafeInteger(options.deadlineAt)&&Date.now()={} + for(let p=pin.registryDirectory;;p=path.dirname(p)){ + directories[p]=await identity(p,p===pin.registryDirectory) + if(p===path.dirname(p))break + } + const names=['operator-public.pem','head-'+pin.currentHeadSha256+'.json'] + const content:Record={},identities:Record={} + const collect=async(name:string,maximum:number,expected?:string)=>{ + const filename=path.join(pin.registryDirectory,name) + const before=await identity(filename,true) + content[name]=await readProtected(filename,maximum,expected) + assert.equal(await identity(filename,true),before,'Protected source file changed during read') + identities[name]=before + } + await collect(names[0],4096) + await collect(names[1],8192,pin.currentHeadSha256) + const head=JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(content[names[1]])) + assert(hex(head?.body?.bindingSha256),'Pinned source binding digest required') + const bindingName='binding-'+head.body.bindingSha256+'.json' + await collect(bindingName,8192,head.body.bindingSha256) + await protectedDirectory(pin.registryDirectory) + for(const [directory,wanted]of Object.entries(directories))assert.equal(await identity(directory,directory===pin.registryDirectory),wanted,'Protected source directory changed') + return {directories,identities,content} +} +function same(before:Awaited>,after:Awaited>){ + assert.deepEqual(after.directories,before.directories,'Protected source directory drift') + assert.deepEqual(after.identities,before.identities,'Protected source identity/mode drift') + assert.deepEqual(after.content,before.content,'Protected source content drift') +} +function captured(raw:DatabaseSourceDeploymentPin){ + const pin=Object.freeze({...raw}) + // Existing strict deployment schema rejects unknown/malformed pin fields. + const verifier=createDatabaseSourceBindingVerifier(pin) + return {pin,verifier} +} + +/** Signature/head/source DATA plus actual POSIX protection of the pinned files. + * The supplied pin still needs independent deployment approval; protection + * cannot distinguish a restored clone/generation or authenticate all writers. */ +export async function readProtectedDatabaseSourceBindingRegistry(raw:DatabaseSourceDeploymentPin,options:DatabaseSourceObservationOptions){ + const {pin}=captured(raw);const lifetime={...options};fence(lifetime) + const before=await protectedState(pin);fence(lifetime) + const result=await readDatabaseSourceBindingRegistry(pin);fence(lifetime) + const after=await protectedState(pin);fence(lifetime);same(before,after) + return Object.freeze({...result,platformProtectionVerified:true as const, + generationAuthenticated:false as const,allWriterDrainAuthenticated:false as const,readyAuthorized:false as const}) +} + +/** Same-facade source verification is retained; filesystem JSON is never a + * source observation, writer capability or independent generation authority. */ +export function createProtectedDatabaseSourceBindingVerifier(raw:DatabaseSourceDeploymentPin){ + const {pin,verifier}=captured(raw) + return Object.freeze({async verify(sql:ProbeStorageSql,observation:DatabaseSourceObservation,options:DatabaseSourceObservationOptions){ + const lifetime={...options};fence(lifetime) + const before=await protectedState(pin);fence(lifetime) + const result=await verifier.verify(sql,observation,lifetime);fence(lifetime) + const after=await protectedState(pin);fence(lifetime);same(before,after) + return Object.freeze({...result,platformProtectionVerified:true as const, + generationAuthenticated:false as const,allWriterDrainAuthenticated:false as const,readyAuthorized:false as const}) + }}) +} diff --git a/backend/scripts/writer-profile/protected-source-registry.test.mts b/backend/scripts/writer-profile/protected-source-registry.test.mts new file mode 100644 index 00000000..07436e8c --- /dev/null +++ b/backend/scripts/writer-profile/protected-source-registry.test.mts @@ -0,0 +1,85 @@ +/** Genuine Ed25519/files; POSIX permission metadata is modeled on Windows. + * No SQL, service, deployment or real POSIX acceptance is claimed. */ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import crypto from 'node:crypto' +import {createRequire} from 'node:module' +import type {Stats,BigIntStats} from 'node:fs' +import type {DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.ts' +import type * as ProtectedApi from './protected-source-registry.mts' +import {runInNewContext} from 'node:vm' +import {readProtectedDatabaseSourceBindingRegistry} from './protected-source-registry.mts' +import {readDatabaseSourceBindingRegistry,sourceBindingBytes,sourceBindingCanonical,sourceBindingSha256,DATABASE_SOURCE_SIGNATURE_DOMAINS} from '../../src/db/database-source-binding.ts' +const require=createRequire(import.meta.url),ts=require('typescript') +const helperSource=await fs.readFile(new URL('./operator-files.mjs',import.meta.url),'utf8') +const source=await fs.readFile(new URL('./protected-source-registry.mts',import.meta.url),'utf8') +const deadline=()=>({deadlineAt:Date.now()+10000}) +function compile(text:string){return ts.transpileModule(text,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText} +function evaluate>(text:string,imports:Record,processModel:unknown=process):T{ + const exports:Record={} + runInNewContext(compile(text),{exports,require:(name:string)=>imports[name]??require(name),process:processModel,Buffer,TextDecoder,console}) + return exports as T +} +interface Fixture {pin:DatabaseSourceDeploymentPin;faults:Record;api:typeof ProtectedApi;verificationReads:()=>number;root:string} +async function own(work:(f:Fixture)=>Promise){ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'mm-protected-source-')) + try{ + const key=crypto.generateKeyPairSync('ed25519'),installationId=crypto.randomUUID() + const signed=(body:unknown,domain:string)=>sourceBindingBytes({body,signature:crypto.sign(null,Buffer.from(domain+sourceBindingCanonical(body)),key.privateKey).toString('base64url')}) + const body={format:'database-source-binding-v1',installationId,sourceIdentityId:crypto.randomUUID(),generation:crypto.randomUUID(),ordinal:1, + source:{systemIdentifier:'123',databaseOid:16384,databaseName:'synthetic',sessionUser:'capture',currentUser:'capture',inRecovery:false,serverVersionNum:180004}} + const binding=signed(body,DATABASE_SOURCE_SIGNATURE_DOMAINS.binding),bindingSha256=sourceBindingSha256(binding) + const head=signed({format:'database-source-registry-head-v1',installationId,sourceIdentityId:body.sourceIdentityId,generation:body.generation,ordinal:1,bindingSha256,previousHeadSha256:null},DATABASE_SOURCE_SIGNATURE_DOMAINS.head) + const pin={registryDirectory:root,installationId,publicKeySpkiSha256:sourceBindingSha256(key.publicKey.export({format:'der',type:'spki'})),currentHeadSha256:sourceBindingSha256(head)} + await fs.writeFile(path.join(root,'operator-public.pem'),key.publicKey.export({format:'pem',type:'spki'}),{mode:0o600}) + await fs.writeFile(path.join(root,'head-'+pin.currentHeadSha256+'.json'),head,{mode:0o600}) + await fs.writeFile(path.join(root,'binding-'+bindingSha256+'.json'),binding,{mode:0o600}) + const faults={modeFile:false,modeDirectory:false,symlink:false,replacement:false,lateMode:false,changed:false};let verificationReads=0 + const metadata=(s:Stats|BigIntStats,filename:string,big:boolean)=>{ + const n=(value:number)=>big?BigInt(value):value,isRoot=filename===root,isFile=s.isFile() + const mode=(isFile?0o100600:0o40700)|((isFile&&faults.modeFile||isRoot&&faults.modeDirectory||isFile&&faults.lateMode&&faults.changed)?0o022:0) + return {...s,mode:n(mode),uid:n(1000),gid:n(1000),ino:faults.replacement&&faults.changed&&isFile?(typeof s.ino==='bigint'?s.ino+1n:s.ino+1):s.ino, + isFile:()=>isFile,isDirectory:()=>s.isDirectory(),isSymbolicLink:()=>faults.symlink&&isRoot} + } + const modelFs={...fs,lstat:async(filename:string,options?:{bigint?:boolean})=>metadata(await fs.lstat(filename,{bigint:options?.bigint===true}),filename,options?.bigint===true),open:async(filename:string,flags:number)=>{ + const file=await fs.open(filename,flags) + return {stat:async()=>metadata(await file.stat(),filename,false),read:file.read.bind(file),close:file.close.bind(file)} + }} + const processModel={platform:'linux',getuid:()=>1000} + const proven=evaluate(helperSource,{'node:fs/promises':modelFs},processModel) + const bindingModule={readDatabaseSourceBindingRegistry:async(p:DatabaseSourceDeploymentPin)=>{ + verificationReads++;const result=await readDatabaseSourceBindingRegistry(p);faults.changed=true;return result + },createDatabaseSourceBindingVerifier:()=>({verify:async()=>{verificationReads++;faults.changed=true;return {genuineSourceFactsMatched:true,generationAuthenticated:false,readyAuthorized:false}}})} + const api=evaluate(source,{'node:fs/promises':modelFs,'./operator-files.mjs':proven,'../../src/db/database-source-binding.js':bindingModule},processModel) + await work({pin,faults,api,verificationReads:()=>verificationReads,root}) + }finally{const rel=path.relative(os.tmpdir(),root);assert(rel&&!rel.startsWith('..')&&!path.isAbsolute(rel));await fs.rm(root,{recursive:true,force:true})} +} + +test('unsupported Windows refuses before registry signature reads',{skip:process.platform!=='win32'},async()=>{ + await assert.rejects(readProtectedDatabaseSourceBindingRegistry({registryDirectory:path.resolve('not-created-protected-source'),installationId:crypto.randomUUID(),publicKeySpkiSha256:'a'.repeat(64),currentHeadSha256:'b'.repeat(64)},deadline()),/Windows ACL/) +}) +test('proven POSIX helpers plus genuine signature/head bytes retain false generation/READY',()=>own(async f=>{ + const value=await f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline()) + assert.equal(value.platformProtectionVerified,true);assert.equal(value.generationAuthenticated,false);assert.equal(value.allWriterDrainAuthenticated,false);assert.equal(value.readyAuthorized,false) + assert.equal(value.headSha256,f.pin.currentHeadSha256);assert.equal(f.verificationReads(),1) +})) +for(const fault of ['modeFile','modeDirectory','symlink'])test(fault+' refuses before cryptographic registry read',()=>own(async f=>{ + f.faults[fault]=true;await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline()));assert.equal(f.verificationReads(),0) +})) +for(const fault of ['replacement','lateMode'])test(fault+' during genuine signature verification refuses protected output',()=>own(async f=>{ + f.faults[fault]=true;await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline()));assert.equal(f.verificationReads(),1) +})) +test('expired/aborted lifetime refuses before filesystem or source access',()=>own(async f=>{ + const abort=new AbortController();abort.abort() + for(const options of [{deadlineAt:Date.now()-1},{deadlineAt:Date.now()+1000,signal:abort.signal}])await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,options)) + assert.equal(f.verificationReads(),0) +})) +test('source verifier drift cannot grant protected output',()=>own(async f=>{ + f.faults.lateMode=true + const verify=f.api.createProtectedDatabaseSourceBindingVerifier(f.pin).verify + await assert.rejects(verify({} as Parameters[0],{} as Parameters[1],deadline())) + assert.equal(f.verificationReads(),1) +})) diff --git a/backend/scripts/writer-profile/publisher.mjs b/backend/scripts/writer-profile/publisher.mjs index 5d4bc287..62c8f35b 100644 --- a/backend/scripts/writer-profile/publisher.mjs +++ b/backend/scripts/writer-profile/publisher.mjs @@ -141,14 +141,14 @@ async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){ const first=await ownedContext(s,installation,PUBLISHER,'publisher-read') if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned preflight source mismatch') const facade=Object.freeze({query:(sql,values)=>s.query(sql,values)}),options={deadlineAt} - const observation=await captureDatabaseSourceObservation(facade,options);await sourceVerifier.verify(facade,observation,options) + const observation=await captureDatabaseSourceObservation(facade,options),initialSource=await sourceVerifier.verify(facade,observation,options) const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s) const current=await readCurrent(s,installation) if(!current)throw Error('Current signed policy required for preflight') const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})),manifestSha256=migrationManifestDigest() if(approval.expiresAt<=Date.now()||approval.manifestSha256!==manifestSha256||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual source44 catalogue') if(canonical(current.policy.source)!==canonical(sourceCoordinates(first))||canonical(current.policy.roles)!==canonical(security.roles)||current.policy.manifestSha256!==manifestSha256||current.policy.definitionSha256!==catalogue.definitionSha256||current.policy.catalogSha256!==catalogSha256)throw Error('Current signed policy differs from actual source/roles/catalogue') - await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options) + const terminalSource=await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options) sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read')) // Recheck the live interval after the potentially lengthy catalogue walk. const clock=(await s.query('SELECT (EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now')).rows @@ -157,7 +157,7 @@ async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){ if(approval.expiresAt<=Date.now())throw Error('Preflight approval expired before terminal') const terminal=await s.query('ROLLBACK');if(terminal.command!=='ROLLBACK')throw Error('Preflight terminal unconfirmed');s.begun(false) return Object.freeze({state:'matched',epoch:current.epoch,headSha256:current.headSha256,notAfter:current.policy.notAfter, - sourceMatched:true,rolesMatched:true,catalogueMatched:true,writerAuthorized:false,generationAuthenticated:false,readyAuthorized:false}) + sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:initialSource?.platformProtectionVerified===true&&terminalSource?.platformProtectionVerified===true,writerAuthorized:false,generationAuthenticated:false,readyAuthorized:false}) }) } async function boundedPending(filename){ diff --git a/backend/src/db/bootstrap.ts b/backend/src/db/bootstrap.ts index 2ab8feb8..37e1fb44 100644 --- a/backend/src/db/bootstrap.ts +++ b/backend/src/db/bootstrap.ts @@ -3,10 +3,14 @@ import { applySqlMigrations } from "./migrate.js" import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js" import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.js" import { env } from "../config.js" +import { mcpStateDirectory } from "../modules/mcp/security.js" +import { assertDatabaseMaintenanceStartup } from "../services/database-maintenance-startup.js" +import { reconcileDatabaseRestoreAudits } from "../services/database-restore-audit.js" const ETL_LOCK = 8723101 export async function initDatabase(): Promise { + await assertDatabaseMaintenanceStartup(mcpStateDirectory(), env.DATABASE_URL) await pool.query("SELECT 1") await applySqlMigrations(pool) await ensurePartitionsAround(pool) @@ -39,4 +43,5 @@ export async function initDatabase(): Promise { } await dropExpiredPartitions(pool) await ensurePartitionsAround(pool) + await reconcileDatabaseRestoreAudits(pool, mcpStateDirectory()) } diff --git a/backend/src/index.ts b/backend/src/index.ts index eb461aa7..a1c9fe48 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,8 @@ import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zo import { monitorEventLoopDelay } from "node:perf_hooks" import { env } from "./config.js" import { initDatabase } from "./db/bootstrap.js" +import { mcpStateDirectory } from "./modules/mcp/security.js" +import { assertDatabaseMaintenanceStartup } from "./services/database-maintenance-startup.js" import { closePool } from "./db/index.js" import authPlugin, { requireApiAuth } from "./plugins/auth.js" import { installDatabaseRequestGate } from "./plugins/database-request-gate.js" @@ -186,6 +188,8 @@ const isMain = if (isMain) { try { + // Resolve external maintenance before init/migrations or consumer startup. + await assertDatabaseMaintenanceStartup(mcpStateDirectory(), env.DATABASE_URL) await initDatabase() const app = await buildApp() let shuttingDown = false diff --git a/backend/src/modules/mcp/control-path-context.ts b/backend/src/modules/mcp/control-path-context.ts index dac27196..065575fb 100644 --- a/backend/src/modules/mcp/control-path-context.ts +++ b/backend/src/modules/mcp/control-path-context.ts @@ -1,3 +1,4 @@ +import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js" import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing" import { nativeBgpRows, type NativeBgpRow } from "../../services/bgp-export-model.js" import { stableBgpPolicyRow } from "../../services/bgp-filter-binding-model.js" @@ -56,7 +57,7 @@ export async function readControlPathContext(profile: RouteAiProfile, candidateI return matching[0] } const sourceId = profile.serverId, table = unique(await get(sourceId, "/routing/table"), profile.probeVrf) - if (![true, "true", "yes"].includes(table.fib as string | boolean) || [true, "true", "yes"].includes(table.disabled as string | boolean) || [true, "true", "yes"].includes(table.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB probe VRF контрольного пути не подтверждён", 409) + if (!isConfirmedRoutingFibTable(table)) throw new McpError("STATE_UNAVAILABLE", "FIB probe VRF контрольного пути не подтверждён", 409) const vrfs = await get(sourceId, "/ip/vrf"), effectiveVrfs: NativeBgpRow[] = [] await enabled(sourceId, "/ip/vrf", unique(vrfs, profile.probeVrf)) for (const row of vrfs) effectiveVrfs.push([true, "true", "yes"].includes(row.disabled as string | boolean) ? row : await enabled(sourceId, "/ip/vrf", row)) diff --git a/backend/src/modules/mcp/database-restore.test.ts b/backend/src/modules/mcp/database-restore.test.ts index 85eb4305..4b6ecd94 100644 --- a/backend/src/modules/mcp/database-restore.test.ts +++ b/backend/src/modules/mcp/database-restore.test.ts @@ -31,6 +31,15 @@ const verifyData = process.env.MMAPP_OWN_TERMINAL_DATA_EQUIVALENCE === "true" let dataExpectation: Awaited> | undefined const originalConnect = pool.connect let migrationCaptures = 0 +async function waitForDatabaseRetirement() { + let timer: ReturnType | undefined + try { + await Promise.race([sharedDatabaseGate.whenOpen(), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error("Terminal restore did not retire its database admission")), 10000) + })]) + } finally { clearTimeout(timer) } +} + try { // Every owned restore profile archives inert domain collectors, not only the // data-equivalence profile. Otherwise restart can launch unrelated downloads @@ -138,6 +147,10 @@ try { await assert.rejects(assertPortalClaimsCurrent(revokedOwner)) await assert.rejects(authenticateMcpToken(issued.token)) await assert.rejects(readRestoreReceipt(operation.id, { ...actor, permissions: ["mm:mcp:read"] })) + // The applied receipt is durable before the terminal callback releases its + // last SQL lease/exclusive admission. Await that existing owner, rather than + // racing its cleanup with the stale-callback/no-replay assertion below. + await waitForDatabaseRetirement() // A stale callback or process replay cannot invoke pg_restore a second time. await pool.query("UPDATE mcp_restore_acceptance_marker SET value='after-receipt'") await executeMcpDatabaseRestore(operation.id, principal) @@ -165,6 +178,7 @@ try { await delay(100) } assert.equal(receipt.state, "blocked", JSON.stringify(receipt)) + await waitForDatabaseRetirement() assert.equal(await accessGeneration(), generationBeforeCorruption) assert.equal((await pool.query("SELECT value FROM mcp_restore_acceptance_marker")).rows[0].value, "after-receipt") console.log("✓ Tampered archive is blocked before data replacement") diff --git a/backend/src/modules/mcp/database-restore.ts b/backend/src/modules/mcp/database-restore.ts index 78452f50..ecddf30e 100644 --- a/backend/src/modules/mcp/database-restore.ts +++ b/backend/src/modules/mcp/database-restore.ts @@ -5,7 +5,7 @@ import { z } from "zod" import { pool } from "../../db/index.js" import { hasPermission, type AuthUser } from "../../lib/permissions.js" import { validateSystemDatabaseRestoreArchive } from "../../services/system-database-restore-execution.js" -import { restoreSystemDatabaseBackup, assertSystemDatabaseRestoreTools } from "../../services/system-database-backup.js" +import { restoreSystemDatabaseBackup, assertSystemDatabaseRestoreTools, withSystemDatabaseRestoreAdmission } from "../../services/system-database-backup.js" import { withVerifiedConfigurationChange } from "../../services/config-mutation-policy.js" import { accessGeneration, decryptBinary, encryptBinary, decryptSecret, McpError, mcpStateDirectory } from "./security.js" import { audit, validatePrincipal, type McpPrincipal } from "./store.js" @@ -14,6 +14,7 @@ import type { add } from "./actions.js" import type { ChangeEvidence } from "./action-types.js" import { withApplicationMutationLock } from "../../services/config-mutation-lock.js" import {assertOriginalMcpTemporaryGrant,type OriginalServiceGrant} from "./temporary-grant-origin.js" +import {reconcileRestoreReceiptAudit,inspectRestoreReceiptAudit} from "../../services/database-restore-audit.js" export const MAX_RESTORE_ARCHIVE_BYTES = 64 * 1024 * 1024 const metadataSchema = z.object({ id: z.string().uuid(), ownerId: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), sizeBytes: z.number().int().positive().max(MAX_RESTORE_ARCHIVE_BYTES), generation: z.string(), expiresAt: z.string().datetime(), createdAt: z.string().datetime() }).strict() @@ -76,6 +77,10 @@ export async function readRestoreReceipt(id: string, actor: AuthUser) { try { return JSON.parse(await readFile(filename(id, ".receipt.json"), "utf8")) as Record } catch { throw new McpError("NOT_FOUND", "Внешняя квитанция восстановления не найдена", 404) } } +export async function readRestoreReceiptAuditStatus(id:string,actor:AuthUser){ + await readRestoreReceipt(id,actor) + return inspectRestoreReceiptAudit(pool,mcpStateDirectory(),id) +} export function registerDatabaseRestore(register: typeof add) { register("database.restore", "mm_database_restore_plan", "Восстановить БД из загруженного человеком архива", "settings", "mm:settings:admin", "write", "global", z.object({ archiveRef: z.string().uuid(), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(), { prepare: async (input, context) => { @@ -91,15 +96,17 @@ export function registerDatabaseRestore(register: typeof add) { } /** This terminal operation must never hold a database-request admission while asking for an exclusive restore. */ export async function executeMcpDatabaseRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) { - const lease = await pool.connect() - let locked = false, unsafe = false - try { - locked = (await lease.query<{ locked: boolean }>("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS locked", ["mcp-operation:" + operationId])).rows[0].locked - if (locked) await executeRestore(operationId, principal,originalGrant) - } finally { - if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true } - lease.release(unsafe) - } + return withSystemDatabaseRestoreAdmission(async () => { + const lease = await pool.connect() + let locked = false, unsafe = false + try { + locked = (await lease.query<{ locked: boolean }>("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS locked", ["mcp-operation:" + operationId])).rows[0].locked + if (locked) await executeRestore(operationId, principal,originalGrant) + } finally { + if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true } + lease.release(unsafe) + } + }) } async function executeRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) { const { loadPlan } = await import("./runtime.js") @@ -110,7 +117,7 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi let receipt: Record = { operationId, archiveRef: payload.archiveRef, state: "running", startedAt: new Date().toISOString(), automaticRetry: false } try { await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt), true) } catch (error) { if (error instanceof Error && "code" in error && error.code === "EEXIST") return; throw error } - let attempted = false, stage = "authority" + let attempted = false, stage = "authority", restoreActorId:string|undefined const assertAuthority = async () => { await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant) const connection = await validatePrincipal(principal) @@ -124,6 +131,7 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi } try { const { connection, ids } = await assertAuthority(), source = await archive(payload.archiveRef, connection.owner_id) + restoreActorId=connection.owner_id stage = "archive-read" if (source.sha256 !== payload.expectedSha256) throw new McpError("STATE_CONFLICT", "Архив подменён", 409) const buffer = await decryptBinary(await readFile(filename(source.id, ".archive.bin"))) @@ -139,7 +147,6 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi receipt = { ...receipt, state: "applied", finishedAt: new Date().toISOString(), accessRevoked: true, warnings: Object.keys(outcome).filter(key => key.endsWith("Error") && outcome[key as keyof typeof outcome]) } stage = "receipt-publish" await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt)) - await audit(pool, connection.owner_id, "database.restore.receipt", null, receipt).catch(() => {}) } catch (error) { // The isolated destructive acceptance runner can print bounded diagnostics // without archive SQL, server credentials, or arbitrary error text. @@ -154,4 +161,9 @@ async function executeRestore(operationId: string, principal: McpPrincipal,origi await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt)) if (!attempted) await pool.query("UPDATE mcp_operations SET state='rejected',error_code=$2,finished_at=now() WHERE id=$1", [operationId, receipt.errorCode]).catch(() => {}) } + // Audit failure is a separate pending/unconfirmed import AFTER durable + // applied receipt publication. It must never relabel confirmed restore. + if(receipt.state==="applied"){ + try{await reconcileRestoreReceiptAudit(pool,mcpStateDirectory(),operationId,restoreActorId)}catch{/* Startup reconciles the existing external receipt; never replay restore. */} + } } diff --git a/backend/src/modules/mcp/operation-execution-origin.ts b/backend/src/modules/mcp/operation-execution-origin.ts index 89e5db91..247b9d9a 100644 --- a/backend/src/modules/mcp/operation-execution-origin.ts +++ b/backend/src/modules/mcp/operation-execution-origin.ts @@ -9,6 +9,7 @@ import { performance } from "node:perf_hooks" import type { QueryConfig, PoolClient } from "pg" import { mcpTemporaryGrantSchema, type McpObjectRef } from "@mmapp/contracts/mcp" import { pool } from "../../db/index.js" +import { sharedDatabaseGate } from "../../services/shared-database-gate.js" import { assertNoConfigurationLockContext } from "../../services/config-mutation-lock.js" import { assertNoOperationConfigurationLockContext } from "../../services/operation-config-lock-lease.js" import { validatePrincipal, audit, type McpPrincipal, type StoredOperation } from "./store.js" @@ -200,7 +201,10 @@ async function interrupt(entry: Admission) { const stopped = new Promise((_yes, no) => { timeout = setTimeout(() => { retired = true; client?.release(true); client = undefined; no(Error("Operation root cleanup deadline")) }, 2500) }) const work = async () => { const owned = await connect - const query = (text: string, values: unknown[] = []) => owned.query({ text, values, query_timeout: Math.max(1, Math.min(1000, deadline - Date.now())) } as QueryConfig) + const query = (text: string, values: unknown[] = []) => { + if (retired || Date.now() >= deadline || !sharedDatabaseGate.hasActiveAdmission()) throw Error("Late cleanup SQL") + return owned.query({ text, values, query_timeout: Math.max(1, Math.min(1000, deadline - Date.now())) } as QueryConfig) + } if ((await query("BEGIN")).command !== "BEGIN") throw Error("No cleanup BEGIN ACK") const result = await query(`UPDATE public.mcp_operations SET state='unknown',verification='inconclusive',error_code='PROCESS_INTERRUPTED', result=jsonb_build_object('needsReview',true,'automaticRetry',false),finished_at=now() WHERE id=$1 AND plan_id=$2 AND connection_id=$3 @@ -217,11 +221,21 @@ async function interrupt(entry: Admission) { try { await Promise.race([work(), stopped]) } catch { console.error("MCP operation root cleanup deferred", { operationId: entry.operationId, retryAllowed: false }) } finally { retired = true; clearTimeout(timeout); if (client) { client.release(!safe); client = undefined } } } +/** Cleanup SQL owns a live request/exclusive admission. Waiting for repair is + * detached from runner slots; only admission is retried, never native work. */ +async function interruptWithinDatabaseAdmission(entry: Admission): Promise { + if (sharedDatabaseGate.hasActiveAdmission()) return sharedDatabaseGate.withinAdmission(() => interrupt(entry)) + if (!sharedDatabaseGate.isPaused()) return sharedDatabaseGate.request(() => interrupt(entry)) + void (async () => { + while (sharedDatabaseGate.isPaused()) await sharedDatabaseGate.whenOpen() + await sharedDatabaseGate.request(() => interrupt(entry)) + })().catch(() => console.error("MCP operation root cleanup deferred", { operationId: entry.operationId, retryAllowed: false })) +} /** Aborted admission/queue ownership only. A serialized/foreign cap is inert. */ export async function retireMcpOperationAdmission(admission: unknown): Promise { const entry = admission && typeof admission === "object" ? admissions.get(admission) : undefined if (!entry) return - admissions.delete(admission as object); await interrupt(entry) + admissions.delete(admission as object); await interruptWithinDatabaseAdmission(entry) } /** Called exactly by the ordinary runtime before its first pool checkout/lock. * The callback is fixed runner code; callers supply no metadata/authorizer. */ @@ -270,7 +284,7 @@ export async function withMcpOperationExecutionOrigin(admission: unknown, work: await roots.run(entry, work) } finally { if (entry) { entry.active = false; clearTimeout(entry.timer); entry.controller.abort() } - if (interruptOwn) await interrupt(admitted) + if (interruptOwn) await interruptWithinDatabaseAdmission(admitted) } } export async function captureMcpOperationExecutionOrigin(): Promise { diff --git a/backend/src/modules/mcp/routing-community-context.ts b/backend/src/modules/mcp/routing-community-context.ts index 3e17d6ce..2a103b31 100644 --- a/backend/src/modules/mcp/routing-community-context.ts +++ b/backend/src/modules/mcp/routing-community-context.ts @@ -1,3 +1,5 @@ +import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js" +import { prepareOriginalEffectSelection } from "./routing-original-effects.js" import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing" import { pool } from "../../db/index.js" import { nativeBgpRows, bgpTemplateReferences, type NativeBgpRow } from "../../services/bgp-export-model.js" @@ -96,7 +98,7 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions } const sourceId = profile.serverId, tables = await get(sourceId, "/routing/table"), vrfs = await get(sourceId, "/ip/vrf") const table = unique(tables, profile.table) - if (![true, "true", "yes"].includes(table.fib as string | boolean) || [true, "true", "yes"].includes(table.disabled as string | boolean) || [true, "true", "yes"].includes(table.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB таблицы назначения не подтверждён", 409) + if (!isConfirmedRoutingFibTable(table)) throw new McpError("STATE_UNAVAILABLE", "FIB таблицы назначения не подтверждён", 409) await enabled(sourceId, "/ip/vrf", unique(vrfs, profile.probeVrf)) // Keep ordered VRF membership (first matching row wins), never equate an // arbitrary policy table with the diagnostic VRF's same-name table. @@ -140,7 +142,7 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions for (const property of ["vrf", "routing-table"]) if (!values[property] && instance) values[property] = bgpFilterName(await post(sourceId, "/routing/bgp/instance/get", { ".id": instance[".id"], "value-name": property })) if (!values.vrf || !values["routing-table"] || !values.afi || values["input.filter"] === null) throw new McpError("STATE_UNAVAILABLE", "Не подтверждены effective BGP table/VRF/AFI/input binding", 409) const boundTable = unique(tables, values["routing-table"]) - if (![true, "true", "yes"].includes(boundTable.fib as string | boolean) || [true, "true", "yes"].includes(boundTable.disabled as string | boolean) || [true, "true", "yes"].includes(boundTable.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB BGP таблицы не подтверждён", 409) + if (!isConfirmedRoutingFibTable(boundTable)) throw new McpError("STATE_UNAVAILABLE", "FIB BGP таблицы не подтверждён", 409) await enabled(sourceId, "/ip/vrf", unique(vrfs, values.vrf)) bindings.push({ connectionId: connection[".id"], instance: instance ? String(instance.name) : null, vrf: values.vrf, table: values["routing-table"]!, afi: values.afi, inputChain: values["input.filter"]!, outputChain: values["output.filter-chain"] ?? "" }) } @@ -177,7 +179,7 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions let gateway: ReturnType try { gateway = routeGatewayContext(candidate.gateway) } catch { throw new McpError("STATE_UNAVAILABLE", "Gateway кандидата не подтверждён", 409) } const resolver = unique(tables, gateway.resolverTable) - if (![true, "true", "yes"].includes(resolver.fib as string | boolean) || [true, "true", "yes"].includes(resolver.disabled as string | boolean) || [true, "true", "yes"].includes(resolver.invalid as string | boolean)) throw new McpError("STATE_UNAVAILABLE", "FIB resolver таблицы не подтверждён", 409) + if (!isConfirmedRoutingFibTable(resolver)) throw new McpError("STATE_UNAVAILABLE", "FIB resolver таблицы не подтверждён", 409) if (unavailableIds.includes(candidate.jumpHostId) || unavailableIds.includes(candidate.exitNodeId)) throw new McpError("DEVICE_UNAVAILABLE", "Источник кандидата отсутствует или отключён", 409) } catch (error) { unavailableCandidate(candidate.id, error) } } @@ -250,6 +252,14 @@ async function readContext(profile: RouteAiProfile, options: RouteContextOptions nativeRuleId: selected && current ? filters.find(row => row.chain === selected.inputChain)![".id"] : null, ownedCompilerRecognized: !!current, sharedConnectionIds, bindings, disabledConnectionIds: disabledBindings.map(binding => binding.connectionId), externalReferences, executionAvailable: reason === null, executionReason: reason, scopedExecutionAvailable: scopedReason === null, scopedExecutionReason: scopedReason, targetRuleProof, dependencyProof, sourceProofs: sources.expected } return { before, clients, fence, probeEndpoints, ownedTarget, + captureOriginalEffectSelection: async (candidateId: string) => { + try { + const routes = await get(sourceId, "/routing/route"), sessions = await get(sourceId, "/routing/bgp/session") + await fence() + return prepareOriginalEffectSelection(profile, candidateId, selected, { routes, connections, sessions, + candidate: candidateRows.get(candidateId), probe: probeEndpoints.get(candidateId) }) + } catch { await fence(); return { status: "unknown" as const, reason: "prepared-effect-catalogue-unavailable" } } + }, signEvidence: (value: unknown) => sign(pathProofTag("community") + fingerprint({ dependencyProof, targetRuleProof, value })), verifyEvidence: (value: unknown, proof: unknown) => typeof proof === "string" && constantEqual(sign(pathProofTag("community") + fingerprint({ dependencyProof, targetRuleProof, value })), proof), } diff --git a/backend/src/modules/mcp/routing-community-scoped-acceptance.ts b/backend/src/modules/mcp/routing-community-scoped-acceptance.ts index 64acf7d0..699b0abf 100644 --- a/backend/src/modules/mcp/routing-community-scoped-acceptance.ts +++ b/backend/src/modules/mcp/routing-community-scoped-acceptance.ts @@ -1,6 +1,10 @@ import { createRoutingReservationStore } from "./routing-reservation-store.js" import { observeCommittedScopedCheckpoint } from "../../test/committed-scoped-checkpoint-observer.js" -import { prepareRoutingControllerBaseline } from "./routing-controller-baseline.js" +import { prepareRoutingControllerBaseline, advanceRoutingControllerBaseline } from "./routing-controller-baseline.js" +import { loadScopedConfigurationPacket, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js" +import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js" +import { originalEffectReportSchema, originalEffectSelectionSchema } from "./routing-original-effects.js" +import { tickRoutingProfiles } from "./routing-profiles.js" import { saveControllerProfileCas } from "./routing-controller-fence.js" import { withApplicationMutationLock, withConfigMutationLock } from "../../services/config-mutation-lock.js" import { performance } from "node:perf_hooks" @@ -31,7 +35,7 @@ import { registerManagementChannel } from "./management.js" import { isSchedulerJobsPaused, pauseSchedulerJobsAndWait, resumeSchedulerJobs } from "../../services/scheduler-running.js" /** Disposable SQL + loopback HTTP only. Does not access or configure CHR. */ -export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" } = {}) { +export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalEffectsOnly?: boolean; originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" | "mixed-forward" } = {}) { const sid = 131, writer: AuthUser = { ...actor, permissions: [...new Set([...actor.permissions, "mm:filters:write"])] } const initial = compileManagedFilterRules([{ community: "65000:10", gateway: "192.0.2.1", outIface: "", action: "route", description: "" }, { community: "65000:20", gateway: "192.0.2.5", outIface: "", action: "route", description: "" }]) const filters = ["a", "b"].map(key => ({ ".id": "*f" + key, chain: "custom-" + key, rule: initial, disabled: "false", comment: managedComment("NEVER_SCOPED_COMMENT_" + key), opaque: "NEVER_SCOPED_OPAQUE" })) @@ -42,6 +46,22 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: let forwardRoundBulk = 0, afterRoundLogRead: (() => Promise) | undefined let primaryDown = false, primaryControlRequests = 0, pendingInversePost = false, inversePostChecks = 0 const observedVrfs = new Set() + // Only this separately selected SQL/localhost phase changes its wire data. + // These are declared synthetic RouterOS responses, never actual CHR facts. + let originalEffectCase: "transition" | "no-op" | "ambiguous" = "transition" + const originalEffectWire: Array<{ method: string; path: string; afterPatch: boolean }> = [] + const originalEffectChanged = () => originalEffectCase === "no-op" || filters[0].rule !== initial + const effectRoutes = () => { + const changed = originalEffectChanged() + const row = { ".id": "*effect1", afi: "ip4", "routing-table": "tenant-a", "dst-address": "203.0.113.0/24", + bgp: "true", connect: "false", static: "false", active: "true", contribution: "active", ecmp: "false", + disabled: "false", filtered: "false", unreachable: "false", blackhole: "false", "bgp.communities": "65000:10", + gateway: changed ? "192.0.2.9" : "192.0.2.1", "immediate-gw": (changed ? "203.0.113.9" : "203.0.113.1") + "%wan-a" } + return originalEffectCase === "ambiguous" ? [row, { ...row, ".id": "*effect2" }] : [row] + } + const effectConnection = { ".id": "*ca", name: "peer-a", instance: "default", vrf: "main", "routing-table": "tenant-a", afi: "ip", + "input.filter": "custom-a", "output.filter-chain": "", templates: "", disabled: "false", "use-bfd": "false", + "local.address": "198.18.0.1", "remote.address": "198.18.0.2", "remote.as": "65001" } const collections = (path: string): Record[] => ({ "/system/resource": [{ version: "7.20.1" }], ...Object.fromEntries(["/routing/route", "/routing/bgp/session", "/routing/bfd/session", "/log"].map(resource => [resource, @@ -54,12 +74,25 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: "/ip/route": [1, 9].map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })), "/interface": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*if" + index, name, disabled: "false", running: "true" })), "/ip/address": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*ad" + index, interface: name, address: "198.51.100." + (index + 1) + "/32", disabled: "false", invalid: "false" })), + ...(options.originalEffectsOnly ? { + "/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "", disabled: "false", invalid: "false" })), + "/routing/route": effectRoutes(), "/routing/bgp/connection": [effectConnection], + "/routing/bgp/session": [{ ...effectConnection, ".id": "*sa", "local.afi": "ip", "remote.afi": "ip", established: "true" }], + "/routing/bfd/session": [], "/log": [{ ".id": "*l1", topics: "bgp,route,info", message: "Declared synthetic localhost effect observation; interval coverage unknown" }], + "/ip/route": [1, 9].map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false", + "immediate-gw": "203.0.113." + value + "%wan-a", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })), + } : {}), } as Record[]>)[path] ?? [] let requestFailure: { error: unknown } | undefined const handler: RequestListener = async (req, res) => { requests++; res.setHeader("content-type", "application/json") + if (options.originalEffectsOnly) { + assert.equal(req.socket.localAddress, "127.0.0.1") + assert.equal(req.headers.authorization, "Basic " + Buffer.from("admin:NEVER_SCOPED_PASSWORD").toString("base64")) + } let raw = ""; for await (const chunk of req) raw += chunk const args = raw ? JSON.parse(raw) : {}, path = decodeURIComponent((req.url ?? "").replace(/^\/rest/, "")) + if (options.originalEffectsOnly) originalEffectWire.push({ method: req.method ?? "", path, afterPatch: filters[0].rule !== initial }) if (req.socket.localAddress === "127.0.0.1") { if (/^\/(routing|tool)\//.test(path) || path === "/system/license") primaryControlRequests++ if (primaryDown) { req.socket.destroy(); return } @@ -203,6 +236,111 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: return { id, version: 1 } } const disablePersistent = (id: string) => pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [id]) + if (options.originalEffectsOnly) { + const cases: Array> = [] + for (const effectCase of ["transition", "no-op", "ambiguous"] as const) { + reset(); await refresh(); originalEffectCase = effectCase + const config = profile("a") + for (const candidate of config.candidates) candidate.controlProbe!.sourceAddress = "198.51.100.1" + assert.equal(config.autoRecovery, false) + // The baseline and persistent authority are issued by the SAME actual + // fixed readers/CAS used by existing tests, not hand-written proofs. + const persistent = await persistentFixture(config) + try { + const { plan, input } = await prepare("a", persistent, config) + const sealedSelection = originalEffectSelectionSchema.parse(input.originalEffectSelection) + assert.equal(sealedSelection.status, effectCase === "ambiguous" ? "unknown" : "prepared") + const storedPlan = await loadPlan(plan.planId) + assert.equal(storedPlan.approved_by, writer.id) + assert(!JSON.stringify(await publicPlan(storedPlan)).includes("originalEffectSelection")) + const beforeWrites = writes, wireStart = originalEffectWire.length + const operation = await execute(plan.planId) + assert.equal(operation.state, "applied", "Optional effect unknown must preserve genuine manual configuration/probe acceptance") + assert.equal(writes - beforeWrites, 1) + const rows: Array<{ created_at: Date; state: string; operation_id: string; operation_version: number; step_state: string; + result: { configurationVerified: boolean; controlProbeVerified: boolean; originalEffects: unknown } }> = (await pool.query(`SELECT o.created_at,o.state,p.operation_id,p.operation_version,s.state AS step_state,s.result + FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id JOIN mcp_operation_steps s ON s.operation_id=o.id + WHERE o.id=$1 AND o.connection_id=$2 AND o.plan_id=$3 AND s.step_index=0`, [operation.id, connectionId, plan.planId])).rows + assert.equal(rows.length, 1); assert.equal(rows[0].operation_id, "routing.community.switch") + assert.equal(rows[0].state, "applied"); assert.equal(rows[0].step_state, "verified") + assert.equal(rows[0].result.configurationVerified, true); assert.equal(rows[0].result.controlProbeVerified, true) + const report = originalEffectReportSchema.parse(rows[0].result.originalEffects) + const requestSeal = await assertScopedCommunityRequest(input) + const configuration = await loadScopedConfigurationPacket(input, requestSeal, { operationId: operation.id, stepIndex: 0 }) + assert(configuration); assert.equal(configuration.packet.operationId, operation.id) + assert.equal(configuration.packet.revision, 2); assert.equal(configuration.packet.phase, "CONFIG_APPLIED") + const network = await loadScopedNetworkCheckpoint(input, requestSeal) + assert(network); assert.equal(network.status, "passed"); assert.deepEqual(network.originalEffects, report) + const runningWire = originalEffectWire.slice(wireStart) + const ribBefore = runningWire.filter(row => row.method === "GET" && row.path === "/routing/route" && !row.afterPatch).length + const ribAfter = runningWire.filter(row => row.method === "GET" && row.path === "/routing/route" && row.afterPatch).length + console.log("Owned original effect diagnostic:", JSON.stringify({ case: effectCase, state: operation.state, + status: report.status, reason: report.reason, rounds: report.rounds.length, budget: report.budget ?? null, + ribBefore, ribAfter, runtimeNativeRequests: runningWire.length, lastNativePaths: runningWire.slice(-8).map(row => row.path) })) + assert.equal(ribBefore, effectCase === "ambiguous" ? 0 : 1) + assert.equal(ribAfter, effectCase === "ambiguous" ? 0 : 2) + assert.equal(report.status, effectCase === "transition" ? "observed-expected" : "unknown") + assert.equal(report.switchEffectObserved, effectCase === "transition") + assert.equal(report.logCoverage, "unknown"); assert.equal(report.routeOriginVerified, false) + assert.equal(report.forwardingVerified, false); assert.equal(report.semanticProof, false); assert.equal(report.autoRecoveryAllowed, false) + if (sealedSelection.status === "prepared") { + assert.equal(report.selectionIdentity, fingerprint(sealedSelection)); assert.equal(report.rounds.length, 2) + assert(report.budget && report.budget.calls > 18 && report.budget.calls <= 256) + assert(report.budget.bytes > 0 && report.budget.bytes <= 8 * 1048576) + assert(report.budget.rows > 0 && report.budget.rows <= 10000) + assert(report.budget.deadline <= rows[0].created_at.getTime() + 105000, "Original120s horizon is narrowed by15s publication reserve") + assert(report.rounds.every(round => Date.parse(round.observedAt) < report.budget!.deadline && round.controlProbe === "passed" && round.bgp === "observed-expected" && round.bfd === "not-configured")) + assert.deepEqual(report.transition?.changedPrefixes, effectCase === "transition" ? ["203.0.113.0/24"] : []) + assert.equal(report.transitionAttribution, "unproven") + } + const row = (await pool.query("SELECT state,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND version=1", [persistent.id])).rows[0] + assert(row?.state.baseline) + const advance = () => advanceRoutingControllerBaseline(row.state.baseline, persistent.id, 1, config, row.grant_expires_at.toISOString(), + { principal, connection: principalConnection }, plan.planId, operation.id) + const principalConnection = await effectiveMcpConnection(principal), beforeAdvanceReads = requests + if (effectCase === "transition") { + const advanced = await advance(); assert.equal(advanced.packet.candidateId, "b") + assert.equal(advanced.packet.transition?.operationId, operation.id); assert.equal(advanced.packet.transition?.checkpointProof, configuration.checkpointProof) + } else { + await assert.rejects(advance, (error: unknown) => error instanceof McpError && error.code === "STATE_CONFLICT") + assert.equal(requests, beforeAdvanceReads, "Unknown/no-transition effect gate refuses before another native context read") + } + // Check actual HMAC loading, not only a caller-visible result object. + if (effectCase === "transition") { + const audit: Array<{ id: string; payload: Record }> = (await pool.query("SELECT id,payload FROM mcp_audit WHERE connection_id=$1 AND action='routing.scoped.network-check' AND payload->>'receiptId'=$2", [connectionId, input.receiptId])).rows + assert.equal(audit.length, 1) + try { + await pool.query("UPDATE mcp_audit SET payload=jsonb_set(payload,'{originalEffects,switchEffectObserved}','false'::jsonb) WHERE id=$1", [audit[0].id]) + assert.equal(await loadScopedNetworkCheckpoint(input, requestSeal), null, "Modified effect vector cannot reuse its original network HMAC") + } finally { await pool.query("UPDATE mcp_audit SET payload=$2::jsonb WHERE id=$1", [audit[0].id, JSON.stringify(audit[0].payload)]) } + assert.deepEqual((await loadScopedNetworkCheckpoint(input, requestSeal))?.originalEffects, report) + } + const repeatReads = requests + const repeated = await applyPlan(principal, plan.planId, randomUUID()) + assert.equal(repeated.id, operation.id, "Completed plan returns its original operation without replay") + assert.equal(repeated.state, "applied") + assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_operations WHERE plan_id=$1", [plan.planId])).rows[0].n), 1) + assert.equal(writes - beforeWrites, 1); assert.equal(requests, repeatReads, "Completed approved plan cannot repeat native work") + // Preserve the existing scoped-controller activation guard. A real + // tick pauses HERE before effect dispatch; never call this an + // effect-triggered scheduler pause or enable that unsupported lane. + await tickRoutingProfiles() + const paused = (await pool.query("SELECT enabled,state FROM mcp_routing_profiles WHERE id=$1", [persistent.id])).rows[0] + assert.equal(paused.enabled, false); assert.equal(typeof paused.state.pausedReason, "string") + assert.deepEqual(paused.state.baseline, row.state.baseline) + cases.push({ case: effectCase, manualState: operation.state, configurationCheckpoint: true, actualPrivateExecutionOrigin: true, + effectStatus: report.status, observedFibTransition: report.switchEffectObserved, nativeRibBefore: ribBefore, nativeRibAfter: ribAfter, + sharedBudget: report.budget ?? null, effectBaselineAdvanceAccepted: effectCase === "transition", effectGateNativeReads: effectCase === "transition" ? "fresh-native-checked" : 0, + actualTickPausedByExistingScopedGuard: true, tickEffectPauseAccepted: false, publicManualRevision2EffectOwnerAccepted: false }) + } finally { await disablePersistent(persistent.id) } + } + assert.deepEqual((await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows, dbBefore) + const result = { accepted: true, cases, persistentAuthorityCases: 0, realMcpDispatcher: true, realSql: true, routerOsTransport: "declared-synthetic-localhost-http", + genuineApprovedRunningOrigin: true, actualConfigurationCheckpoint: true, signedEffectTamperRejected: true, + originalAutoRecoveryEnabled: false, fullRoutingAccepted: false, scopedControllerEnabled: false, nativeChrAccepted: false, forwardingVerified: false } + console.log("Original effect SQL/runtime component:", JSON.stringify(result)) + return result + } const assertOriginalCredentialRevocation = async () => { console.log(JSON.stringify({stage:'original-credential-revocation:init'})) reset(); await refresh() @@ -260,6 +398,7 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: const id = randomUUID(), current = await effectiveMcpConnection(principal), config = profile("a") assert.equal(config.autoRecovery, false) if (chargeCase === "quota") config.maxSwitchesPerHour = 1 + if (chargeCase === "mixed-forward") config.maxSwitchesPerHour = 2 const expiry = new Date(Math.min(Date.now() + 600000, current.owner_valid_until.getTime(), Date.parse(principal.credentialExpiresAt), Date.parse(current.policy.expiresAt))) await withApplicationMutationLock(() => withConfigMutationLock([sid], async () => { const baseline = await prepareRoutingControllerBaseline(id, 1, config, expiry.toISOString(), { principal, connection: current }) @@ -292,6 +431,10 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: } console.log(JSON.stringify({stage:"manual-inverse:forward-prepare",chargeCase})) const { plan, input } = await prepare("a", persistent ? { id, version: 1 } : undefined, config) + // Prepare the competing forward while the ORIGINAL native baseline is + // current. Admission must charge it even if execution later refuses the + // changed network state. No invented request/authority or reserved lane. + const competingForward = chargeCase === "mixed-forward" ? await prepare("a", { id, version: 1 }, config) : undefined // Existing known partial configuration path: genuine committed CONFIG_APPLIED, // then failed control probe, then genuine manual inverse positive to A. mode = "post-failed" @@ -348,6 +491,54 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: await assert.rejects(applyPlan(principal, recovery.planId, randomUUID())) assert.deepEqual(await snapshot(), afterForward, "No approval means no debit/charge/native write") await approvePlan(recovery.planId, writer, true) + if (chargeCase === "mixed-forward") { + assert(competingForward) + const attempts = [ + { lane: "forward", planId: competingForward.plan.planId, key: randomUUID(), cost: 2 }, + { lane: "manual-inverse", planId: recovery.planId, key: randomUUID(), cost: 1 }, + ] as const + const initialPlans = await Promise.all(attempts.map(attempt => loadPlan(attempt.planId))) + assert(initialPlans.every(value => value.state === "approved")) + const admitted = await Promise.allSettled(attempts.map(attempt => applyPlan(principal, attempt.planId, attempt.key))) + const winners = admitted.flatMap((value, index) => value.status === "fulfilled" ? [index] : []) + assert.equal(winners.length, 1, "Two writer lanes must compete for the same last profile slot") + const winnerIndex = winners[0], loserIndex = 1 - winnerIndex + const winner = admitted[winnerIndex], loser = admitted[loserIndex] + assert(winner.status === "fulfilled" && loser.status === "rejected") + assert(loser.reason instanceof McpError && loser.reason.code === "STATE_CONFLICT", "The losing lane must fail profile admission") + assert.equal(fingerprint(await loadPlan(attempts[loserIndex].planId)), fingerprint(initialPlans[loserIndex]), "Losing plan is completely unchanged") + assert.equal(Number((await pool.query("SELECT count(*)::int AS n FROM mcp_operations WHERE plan_id=$1", [attempts[loserIndex].planId])).rows[0].n), 0) + assert.equal(Number((await pool.query("SELECT count(*)::int AS n FROM mcp_routing_forward_charges WHERE source_plan_id=$1", [attempts[loserIndex].planId])).rows[0].n), 0) + assert.equal(Number((await pool.query("SELECT count(*)::int AS n FROM mcp_routing_manual_inverse_charges WHERE recovery_plan_id=$1", [attempts[loserIndex].planId])).rows[0].n), 0) + const deadline = Date.now() + 140000 + let terminalState = "pending" + while (["pending", "running"].includes(terminalState) && Date.now() < deadline) { + terminalState = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [winner.value.id])).rows[0].state + if (["pending", "running"].includes(terminalState)) await delay(20) + } + assert(!["pending", "running"].includes(terminalState), "Winner must retire before fixture cleanup") + if (requestFailure) throw requestFailure.error + snapshotStage = "mixed-writer-terminal" + const afterMixed = await snapshot() + assert.equal(afterMixed.total, 2) + assert.equal(afterMixed.forward, winnerIndex === 0 ? 2 : 1) + assert.equal(afterMixed.manualInverse, winnerIndex === 1 ? 1 : 0) + assert.equal(afterMixed.reserved, 0); assert.equal(afterMixed.legacy, 0) + assert.equal(afterMixed.generic, afterForward.generic + attempts[winnerIndex].cost, "Losing writer cannot debit generic quota") + if (winnerIndex === 1) { + const charges = (await pool.query("SELECT source_operation_id,source_step_index,recovery_operation_id FROM mcp_routing_manual_inverse_charges WHERE recovery_operation_id=$1", [winner.value.id])).rows + assert.equal(charges.length, 1); assert.equal(charges[0].source_operation_id, source.id) + assert.equal(charges[0].source_step_index, 0); assert.equal(charges[0].recovery_operation_id, winner.value.id) + assert.equal(terminalState, "applied"); assert.equal(afterMixed.writes, 2) + } + const beforeReplay = fingerprint(await snapshot()) + assert.equal((await applyPlan(principal, attempts[winnerIndex].planId, attempts[winnerIndex].key)).id, winner.value.id) + await delay(30) + assert.equal(fingerprint(await snapshot()), beforeReplay, "Same-key observation cannot recharge or replay either lane") + return { accepted: true, mixedWriterAdmission: true, winnerLane: attempts[winnerIndex].lane, before, afterForward, afterMixed, + exactlyOneAdmission: true, losingPlanOperationChargeAndGenericRollback: true, sameKeyReplayUnchanged: true, + realSql: true, allWriterLedgerComplete: false, reservedDispatchEnabled: false, autoRecoveryEnabled: false } + } console.log(JSON.stringify({ stage: "manual-inverse:human-recovery-apply" })) if (chargeCase === "quota" || chargeCase === "revoked") { if (chargeCase === "revoked") await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [id]) diff --git a/backend/src/modules/mcp/routing-community-scoped-checkpoint.ts b/backend/src/modules/mcp/routing-community-scoped-checkpoint.ts index 2de893fd..45937924 100644 --- a/backend/src/modules/mcp/routing-community-scoped-checkpoint.ts +++ b/backend/src/modules/mcp/routing-community-scoped-checkpoint.ts @@ -1,3 +1,4 @@ +import { originalEffectReportSchema, type OriginalEffectReport } from "./routing-original-effects.js" import { z } from "zod" import type { PoolClient } from "pg" import { pool } from "../../db/index.js" @@ -19,7 +20,7 @@ const checkpointSchema = z.union([manualCheckpointSchema, manualCheckpointSchema export type ScopedConfigurationPacket = z.infer type Checkpoint = ScopedConfigurationPacket const networkSchema = z.object({ receiptId: z.string().uuid(), requestSeal: proof, configurationCheckpointProof: proof, - check: z.object({ id: z.string().uuid(), candidateId: z.string(), status: z.enum(["passed", "failed"]), observedAt: z.string().datetime() }).strict(), networkSeal: proof }).strict() + check: z.object({ id: z.string().uuid(), candidateId: z.string(), status: z.enum(["passed", "failed"]), observedAt: z.string().datetime() }).strict(), originalEffects: originalEffectReportSchema.optional(), networkSeal: proof }).strict() const intended = (input: Record) => JSON.stringify(redact({ kind: "service", service: "routing.community.scoped", input })) const connection = (input: Record) => z.object({ connectionId: z.string().uuid() }).parse(input.principal).connectionId const seal = (value: Checkpoint) => privateSecretFingerprint(fingerprint(value), `route-ai-scoped-config-checkpoint-v${value.revision}`) @@ -72,12 +73,13 @@ export async function loadScopedConfigurationCheckpoint(input: Record, requestSeal: string, check: { id: string; candidateId: string; status: "passed" | "failed"; observedAt: string }) { +export async function saveScopedNetworkCheckpoint(input: Record, requestSeal: string, check: { id: string; candidateId: string; status: "passed" | "failed"; observedAt: string }, originalEffects?: OriginalEffectReport) { await assertPersistentScopedProfileAuthority(input) const configuration = await loadScopedConfigurationCheckpoint(input, requestSeal) if (!configuration) throw new McpError("STATE_CONFLICT", "Нет подтверждённого CONFIG_APPLIED", 409) const packet = { receiptId: input.receiptId, requestSeal, configurationCheckpointProof: configuration.checkpointProof, - check: { id: check.id, candidateId: check.candidateId, status: check.status, observedAt: check.observedAt } } + check: { id: check.id, candidateId: check.candidateId, status: check.status, observedAt: check.observedAt }, + ...(originalEffects === undefined ? {} : { originalEffects: originalEffectReportSchema.parse(originalEffects) }) } const networkSeal = await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1") await assertPersistentScopedProfileAuthority(input) await audit(pool, String(input.ownerId), "routing.scoped.network-check", connection(input), { ...packet, networkSeal }) @@ -92,5 +94,5 @@ export async function loadScopedNetworkCheckpoint(input: Record if (!parsed.success) return null const { networkSeal, ...packet } = parsed.data if (packet.configurationCheckpointProof !== configuration.checkpointProof || packet.check.candidateId !== input.candidateId || Date.parse(packet.check.observedAt) < Date.parse(configuration.observedAt) || Date.parse(packet.check.observedAt) > Date.now() || networkSeal !== await privateSecretFingerprint(fingerprint(packet), "route-ai-scoped-network-checkpoint-v1")) return null - return { status: packet.check.status, networkSeal } + return { status: packet.check.status, networkSeal, ...(packet.originalEffects === undefined ? {} : { originalEffects: packet.originalEffects }) } } diff --git a/backend/src/modules/mcp/routing-community-scoped-service.ts b/backend/src/modules/mcp/routing-community-scoped-service.ts index 835c2c34..1d3f0c35 100644 --- a/backend/src/modules/mcp/routing-community-scoped-service.ts +++ b/backend/src/modules/mcp/routing-community-scoped-service.ts @@ -1,3 +1,7 @@ +import { createRoutingNetworkSessionForCurrentExecution } from "./routing-network-execution-session.js" +import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js" +import { resolveRoutingNetworkForwardRoundConfigurationContext, closeRoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.js" +import { originalEffectSelectionSchema, unknownOriginalEffects, originalEffectBaselineMatches, inspectOriginalEffectRounds, type OriginalEffectReport } from "./routing-original-effects.js" import { randomUUID } from "node:crypto" import { z } from "zod" import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing" @@ -25,6 +29,11 @@ import { createRoutingReservationStore } from "./routing-reservation-store.js" const principalSchema = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict() const fail = (message: string, code = "STATE_CONFLICT") => new McpError(code, message, 409) +const effectReadFailure = (stage: "original-effect-rounds-unavailable" | "effect-publication-revalidation-unavailable", error: unknown) => { + const code = error instanceof McpError && ["READ_CANCELLED", "PLAN_EXPIRED", "LIMIT_EXCEEDED", "STATE_CONFLICT", "RESET_REQUIRED", "STATE_UNAVAILABLE"].includes(error.code) + ? error.code : "STATE_UNAVAILABLE" + return unknownOriginalEffects(stage + ":" + code) +} export const scopedProfileIds = (profile: z.infer) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])].sort((a, b) => a - b) const publicRule = (rule: { community: string; gateway: string; outIface: string }) => ({ community: rule.community, action: "route", gateway: rule.gateway, gatewayTunnelId: rule.outIface }) type ObservationPhase = "admission" | "configuration" @@ -80,7 +89,7 @@ async function receipt(input: Record, action: string) { await assertPersistentScopedProfileAuthority(input) await audit(pool, String(input.ownerId), action, principalSchema.parse(input.principal).connectionId, { receiptId: input.receiptId, requestSeal, revision: input.proofRevision }) } -const matches = (input: Record, context: Awaited>) => scopedConfigurationMatches({ dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, candidateId: input.candidateId }, context.before) +const matches = (input: Record, context: { before: Parameters[1] }) => scopedConfigurationMatches({ dependencyProof: input.dependencyProof, originalTargetProof: input.originalTargetProof, desiredTargetProof: input.desiredTargetProof, candidateId: input.candidateId }, context.before) async function original(input: Record, verified: boolean) { await sealedInput(input) const value = await observe(input, undefined, verified) @@ -128,7 +137,8 @@ export async function prepareScopedCommunitySwitch(input: Record = { ...withPath, nativeId: owned.nativeId, outIface, contextSources: value.context.before.sourceProofs, dependencyProof: value.context.before.dependencyProof, + const originalEffectSelection = await value.context.captureOriginalEffectSelection(String(input.candidateId)) + const sealed: Record = { ...withPath, originalEffectSelection, nativeId: owned.nativeId, outIface, contextSources: value.context.before.sourceProofs, dependencyProof: value.context.before.dependencyProof, originalTargetProof: value.context.before.targetRuleProof, desiredTargetProof: owned.desiredTargetProof(compiled.compiledBody), receiptId: randomUUID(), proofRevision: persistent ? 3 : 2, originalCandidateId: value.context.before.currentCandidateId, originalRuleCiphertext: await encryptSecret(String(owned.rawRow.rule)), desiredRuleCiphertext: await encryptSecret(compiled.compiledBody), managementBindingCiphertext, rules: [publicRule(compiled.proposed)], beforeRules: [publicRule(compiled.before)] } @@ -156,7 +166,26 @@ export function registerScopedCommunityService() { if (await receiptExists(input, "routing.scoped.attempt")) throw fail("Повтор PATCH запрещён", "MUTATION_OUTCOME_UNKNOWN") await receipt(input, "routing.scoped.attempt") const before = await original(input, true), owned = before.context.ownedTarget! + let effectSession: Awaited> | undefined + let effectBaseline: Parameters[1] | undefined + let originalEffects: OriginalEffectReport | undefined + const effectSelection = originalEffectSelectionSchema.safeParse(input.originalEffectSelection) + if (effectSelection.success) originalEffects = unknownOriginalEffects(effectSelection.data.status === "unknown" ? effectSelection.data.reason : "effect-acquisition-unavailable") try { + // Optional original-effect vector is never an extra write/recovery grant. + // Old manual configuration+probe acceptance remains authoritative. + if (context?.phase === "forward" && input.proofRevision === 3 && effectSelection.success && effectSelection.data.status === "prepared") { + try { + const selected = effectSelection.data + effectSession = await createRoutingNetworkSessionForCurrentExecution({ servers: scopedProfileIds(before.profile), + probes: [{ key: selected.probe.key, serverId: selected.probe.serverId, destination: selected.probe.destination, + source: selected.probe.source, interface: selected.probe.interface, probeVrf: selected.probe.probeVrf }] }) + effectBaseline = await effectSession.readSnapshot("before") + if (!originalEffectBaselineMatches(selected, effectBaseline)) { + effectSession.close(); effectSession = undefined; originalEffects = unknownOriginalEffects("prepared-effect-baseline-drift") + } + } catch { effectSession?.close(); effectSession = undefined } + } await executeGuardedConfigurationWrite(before.profile.serverId, "PATCH", "/routing/filter/rule/" + owned.nativeId, async () => { // This runs AFTER the durable configuration.intent, immediately before transport. const fresh = await original(input, true); await before.context.fence() @@ -170,13 +199,47 @@ export function registerScopedCommunityService() { await saveScopedConfigurationCheckpoint(input, await sealedInput(input)) const check = await checkCandidatePath(after.profile, after.candidate.id, principalSchema.parse(input.principal).connectionId, { authorize: after.authorize }) if (check.status === "unknown") throw fail("Control probe неизвестен", "MUTATION_OUTCOME_UNKNOWN") - // A probe can be long: prove the whole configuration again before a durable VERIFIED receipt. - const final = await observe(input, undefined, true, undefined, "configuration") - if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe") - await final.context.fence() - await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status }) + if (effectSession && effectBaseline && effectSelection.success && effectSelection.data.status === "prepared") { + let roundContext: Awaited> | undefined + try { + const principal = principalSchema.parse(input.principal), action = { principal, connection: await effectiveMcpConnection(principal) } + roundContext = await resolveRoutingNetworkForwardRoundConfigurationContext(effectSession.sharedContext, action) + const first = await effectSession.readConfigurationBoundForwardSnapshot(roundContext) + const second = await effectSession.readConfigurationBoundForwardSnapshot(roundContext) + const observed = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline), budget = effectSession.readBudget() + originalEffects = { ...observed, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } } + } catch (error) { originalEffects = effectReadFailure("original-effect-rounds-unavailable", error) } + finally { if (roundContext) closeRoutingNetworkForwardRoundConfigurationContext(roundContext) } + } + // Publication revalidation uses the existing native owner's fixed + // context reader and therefore its SAME counters, without a snapshot. + let sharedPublication = false + if (effectSession && !effectSession.readBudget().closed) { + try { + const published = await readRoutingNetworkScopedCommunityContext(effectSession.sharedContext) + if (!matches(input, published).configurationMatchesDesired) throw fail("Состояние изменилось после effect-check") + await published.fence() + const budget = effectSession.readBudget() + originalEffects = { ...originalEffects!, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } } + sharedPublication = true + } catch (error) { originalEffects = effectReadFailure("effect-publication-revalidation-unavailable", error) } + } + if (!sharedPublication) { + // This is the OLD manual final read, relocated after optional work, + // never duplicated or counted as a completed effect-owner read. + // Closed/exhausted effect owners cannot waive this fresh guard. + const final = await observe(input, undefined, true, undefined, "configuration") + if (!matches(input, final.context).configurationMatchesDesired) throw fail("Состояние изменилось после probe") + await final.context.fence() + } + if (effectSession && originalEffects) { + const budget = effectSession.readBudget() + originalEffects = { ...originalEffects, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } } + } + await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status }, originalEffects) return verifiedResult(input) } catch { throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN") } + finally { effectSession?.close() } })), verify: async (input, result) => { const expected = await verifiedResult(input) @@ -200,6 +263,7 @@ async function verifiedResult(input: Record) { const network = await loadScopedNetworkCheckpoint(input, await sealedInput(input)) const result = { outcome: network?.status === "passed" ? "applied" : "configuration-applied-network-unverified", nativeWrites: 1, forwardingVerified: false, configurationVerified: true, receiptId: input.receiptId, proofRevision: input.proofRevision, configurationCheckpointAvailable: !!checkpoint, configurationCheckpointProof: checkpoint?.checkpointProof ?? null, controlProbeVerified: network?.status === "passed", postProbeStatus: network?.status ?? "unknown", networkCheckpointProof: network?.networkSeal ?? null, + ...(network?.originalEffects === undefined ? {} : { originalEffects: network.originalEffects }), nativeVerificationSeal: await privateSecretFingerprint(fingerprint({ request: await sealedInput(input), dependency: input.dependencyProof, desired: input.desiredTargetProof, candidate: input.candidateId, configuration: checkpoint?.checkpointProof ?? null, network: network?.networkSeal ?? null }), `route-ai-scoped-verified-v${input.proofRevision}`) } await assertPersistentScopedProfileAuthority(input) return result diff --git a/backend/src/modules/mcp/routing-controller-baseline.ts b/backend/src/modules/mcp/routing-controller-baseline.ts index a6d6b126..a746c3ef 100644 --- a/backend/src/modules/mcp/routing-controller-baseline.ts +++ b/backend/src/modules/mcp/routing-controller-baseline.ts @@ -4,6 +4,7 @@ import { findMcpOperation } from "./registry.js" import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js" import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js" import { loadScopedConfigurationPacket, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.js" +import { originalEffectSelectionSchema } from "./routing-original-effects.js" import { z } from "zod" import { routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing" import { hasPermission } from "../../lib/permissions.js" @@ -166,6 +167,13 @@ export async function advanceRoutingControllerBaseline(rawBaseline: unknown, pro const configuration = await loadScopedConfigurationPacket(input, requestSeal, { operationId, stepIndex: 0 }) const network = await loadScopedNetworkCheckpoint(input, requestSeal) if (!configuration || configuration.packet.revision !== 2 || !network || network.status !== "passed") throw fail() + // A genuinely prepared effect check may pause controller advancement without + // erasing the separate successful manual configuration/control-probe receipt. + if (Object.hasOwn(input, "originalEffectSelection")) { + const effects = originalEffectSelectionSchema.safeParse(input.originalEffectSelection) + if (!effects.success || effects.data.status !== "prepared" || network.originalEffects?.status !== "observed-expected" || + network.originalEffects.selectionIdentity !== fingerprint(effects.data) || network.originalEffects.switchEffectObserved !== true || !network.originalEffects.transition?.changedPrefixes.length) throw fail() + } const packet = configuration.packet, result = row.result if (packet.operationId !== operationId || packet.stepIndex !== 0 || packet.nativeId !== original.nativeId || packet.originalCandidateId !== original.candidateId || packet.originalTargetProof !== original.targetProof || diff --git a/backend/src/modules/mcp/routing-controls.ts b/backend/src/modules/mcp/routing-controls.ts index 1258efba..82e339a6 100644 --- a/backend/src/modules/mcp/routing-controls.ts +++ b/backend/src/modules/mcp/routing-controls.ts @@ -1,3 +1,5 @@ +import { readRoutingTopology } from "../../services/routing-topology-observation.js" +import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-observation-source.js" import { z } from "zod" import { dedupeVrfAliasSnapshots } from "../../services/vrf-production-action-binding.js" import { pool } from "../../db/index.js" @@ -177,14 +179,26 @@ export function registerRoutingControls(register: typeof add) { }, }) register("routing.topology.read", "mm_routing_topology_get", "Наблюдаемая конфигурация маршрутизации", "network", "mm:network:read", "read", "servers", z.object({ serverId }).strict(), { - read: async input => { - const { client } = await routerClient(Number(input.serverId)) - const paths = { interfaces: "/interface", ipv4: "/ip/route", ipv6: "/ipv6/route", tables: "/routing/table", rules: "/routing/rule", vrfs: "/ip/vrf", ospfNeighbors: "/routing/ospf/neighbor", bgpSessions: "/routing/bgp/session" } - const entries = await Promise.all(Object.entries(paths).map(async ([key, path]) => { - try { const values = await client.get(path); const rows = Array.isArray(values) ? values : [values]; return [key, { items: rows.slice(0, 500), truncated: rows.length > 500, observed: true }] } - catch { return [key, { items: [], observed: false, reason: "Устройство не подтвердило возможность чтения" }] } - })) - return { serverId: input.serverId, observedAt: new Date().toISOString(), resources: Object.fromEntries(entries), observedPath: false } + read: async (input, context) => { + const sid = Number(input.serverId) + if (!context.connection.policy.scope.serverIds.includes(sid)) throw new McpError("SERVER_DENIED", "Источник топологии вне разрешённой области", 403) + const signal = AbortSignal.any([AbortSignal.timeout(30000), ...(context.signal ? [context.signal] : [])]) + const authorize = async () => { + if (signal.aborted) throw new McpError("READ_CANCELLED", "Чтение топологии отменено или превысило срок", 409) + const current = await effectiveMcpConnection(context.principal) + if (!hasPermission(current.owner_permissions, "mm:network:read") || !hasPermission(current.policy.permissions, "mm:network:read")) throw new McpError("PERMISSION_DENIED", "Право чтения сети отозвано", 403) + if (fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions)) throw new McpError("RESET_REQUIRED", "Допуск изменился; повторите чтение топологии", 409) + } + await authorize() + const source = await createRoutingSourceContext(await readRoutingEndpoints([sid])) + if (source.expected.length !== 1) throw new McpError("DEVICE_UNAVAILABLE", "Источник топологии отсутствует или выключен", 409) + const beforeRead = async () => { + await authorize(); await source.assertCurrent() + if (signal.aborted) throw new McpError("READ_CANCELLED", "Чтение топологии отменено или превысило срок", 409) + } + await beforeRead() + const { client } = await routerClient(sid) + return { serverId: sid, ...await readRoutingTopology(client, beforeRead, signal) } }, }) register("routing.ecmp.update", "mm_ecmp_plan", "Равная стоимость маршрутов ECMP и hash policy", "network", "mm:network:write", "write", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length), distance: z.number().int().min(1).max(255).default(1), hashPolicy: z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), { diff --git a/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts b/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts index 7660c543..6a3da884 100644 --- a/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts +++ b/backend/src/modules/mcp/routing-network-configuration-bridge.test.ts @@ -201,12 +201,13 @@ test("missing genuine checkpoint closes shared owner without accepting equality" test("forward round context reuses one admitted source only through its exact ephemeral acquisition", async () => { const f = await fixture(), token = await f.resolveRound() + assert.equal(f.stats().sharedReads, 0, "Round admission grants no native-success observation") for (let round = 0; round < 2; round++) { const before = await f.readRound(token), after = await f.readRound(token) assert.equal(before.operationId, f.row.id); assert.equal(after.securityProof, false) assert.equal(after.forwardingVerified, false); assert.equal(after.sharedNetworkBudgetVerified, true) } - assert.equal(f.stats().sharedReads, 5) + assert.equal(f.stats().sharedReads, 4) await assert.rejects(f.resolveShared(), /already issued/) }) @@ -221,3 +222,16 @@ for (const fault of ["clone", "owner", "body", "dependency", "checkpoint", "revo await assert.rejects(f.readRound(fault === "clone" ? JSON.parse(JSON.stringify(token)) : token, fault === "owner" ? {} : f.acquisition)) assert.equal(f.stats().sharedClosed, !["clone", "close"].includes(fault)) }) + + +for (const drift of ["body", "dependency", "checkpoint", "revoke"] as const) + test("first forward-round read detects " + drift + " after source-only admission", async () => { + const f = await fixture(), token = await f.resolveRound() + assert.equal(f.stats().nativeReads, 0) + if (drift === "body" || drift === "dependency") f.drift(drift) + if (drift === "checkpoint") f.checkpointChanged() + if (drift === "revoke") f.revoke() + await assert.rejects(f.readRound(token)) + assert.equal(f.stats().sharedClosed, true) + await assert.rejects(f.readRound(token)) + }) diff --git a/backend/src/modules/mcp/routing-network-configuration-bridge.ts b/backend/src/modules/mcp/routing-network-configuration-bridge.ts index 89c3b4d9..d717e99d 100644 --- a/backend/src/modules/mcp/routing-network-configuration-bridge.ts +++ b/backend/src/modules/mcp/routing-network-configuration-bridge.ts @@ -68,7 +68,9 @@ export async function resolveRoutingNetworkConfigurationContextFromSharedContext * as the existing bridge. Only its owning session can use it inside a queued * round acquisition; JSON copies and caller-supplied observations cannot. */ export async function resolveRoutingNetworkForwardRoundConfigurationContext(sharedContext: RoutingNetworkSharedContext, action: ActionContext): Promise { - const initial = await resolveRoutingNetworkConfigurationContextFromSharedContext(sharedContext, action) + const binding = inspectRoutingNetworkSharedContext(sharedContext) + if (fingerprint(binding.principal) !== fingerprint(action.principal)) throw fail("Shared configuration principal changed") + const initial = await resolveConfigurationContext(binding.sourceOperationId, action, sharedContext, true) const entry = contexts.get(initial) if (!entry) throw fail("Configuration bridge admission disappeared") contexts.delete(initial) @@ -96,7 +98,8 @@ export function closeRoutingNetworkForwardRoundConfigurationContext(context: Rou roundContexts.delete(context); entry?.close() } -async function resolveConfigurationContext(sourceOperationId: string, action: ActionContext, sharedContext?: RoutingNetworkSharedContext): Promise { +async function resolveConfigurationContext(sourceOperationId: string, action: ActionContext, sharedContext?: RoutingNetworkSharedContext, deferredRoundReadback = false): Promise { + if (deferredRoundReadback && !sharedContext) throw fail("Forward round requires its original shared owner") z.string().uuid().parse(sourceOperationId) const principal = freeze(structuredClone(action.principal)), connection = freeze(structuredClone(action.connection)) const abort = new AbortController() @@ -190,7 +193,11 @@ async function resolveConfigurationContext(sourceOperationId: string, action: Ac bodyIdentity: fingerprint(original.packet.desiredRule), dependencyIdentity: original.packet.dependencyProof }, securityProof: false as const, forwardingVerified: false as const, sharedNetworkBudgetVerified: !!sharedContext }) } - await readback() + // A forward-round token carries no native success observation. Its fixed + // owner must perform a fresh read immediately BEFORE every snapshot and + // another immediately AFTER. Avoid an unused eager native read here; + // independent/shared one-shot bridge semantics remain unchanged. + if (!deferredRoundReadback) await readback() const token = Object.freeze({}) as RoutingNetworkConfigurationContext contexts.set(token, { consumed: false, close, read: readback, consume: async () => { try { return await readback() } diff --git a/backend/src/modules/mcp/routing-network-execution-origin.test.mjs b/backend/src/modules/mcp/routing-network-execution-origin.test.mjs new file mode 100644 index 00000000..da696348 --- /dev/null +++ b/backend/src/modules/mcp/routing-network-execution-origin.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; +const require = createRequire(new URL('../../../package.json', import.meta.url)); +const ts = require('typescript'); +const code = ts.transpileModule(await readFile(new URL('./routing-network-execution-session.ts', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } +}).outputText; +function fixture() { + const now = Date.now(); + const metadata = { approved: true, phase: 'forward', stepIndex: 0, operationName: 'routing.community.switch', operationVersion: 7, + serviceName: 'routing.community.scoped', operationId: 'real-operation', planId: 'real-plan', principal: { connectionId: 'real-connection', credentialExpiresAt: new Date(now + 90000).toISOString() }, + serviceInput: { persistentProfileAuthority: { packet: { validUntil: new Date(now + 70000).toISOString() } } } }; + metadata.sealedEvidenceProof = createHash('sha256').update('evidence-ciphertext').digest('hex'); + metadata.sealedPayloadProof = createHash('sha256').update('payload-ciphertext').digest('hex'); + const original = structuredClone(metadata); + const row = { created_at: new Date(now - 40000), expires_at: new Date(now + 100000), evidence: { sealed: 'evidence-ciphertext' }, payload: { sealed: 'payload-ciphertext' }, + intended_state: { kind: 'service', service: metadata.serviceName, input: structuredClone(metadata.serviceInput) } }; + const lifetime = { expiresAt: now + 60000, monotonicDeadline: performance.now() + 60000, signal: new AbortController().signal }; + let calls = 0, inspections = 0, closed = 0, options, query, originAvailable = true, afterCreate = () => {}; + const connection = { owner_valid_until: new Date(now + 110000), policy: { expiresAt: new Date(now + 120000).toISOString() } }; + const token = Object.freeze({}); + const providers = { + 'node:crypto': { createHash }, + '../../db/index.js': { pool: { query: async value => { query = value; return { rows: [row] }; } } }, + '../../services/config-mutation-policy.js': { + captureVerifiedMcpServiceExecution: async () => { if (!originAvailable) throw Error('no-origin'); return token; }, + inspectVerifiedMcpServiceExecution: async value => { assert.equal(value, token); inspections++; return structuredClone(metadata); }, + inspectVerifiedMcpServiceExecutionLifetime: async value => { assert.equal(value, token); return lifetime; } + }, + './store.js': { effectiveMcpConnection: async principal => { assert.deepEqual(principal, original.principal); return connection; } }, + './registry.js': { findMcpOperation: () => ({ version: 7 }) }, + './security.js': { fingerprint: JSON.stringify, redact: value => value, McpError: Error }, + './routing-profile-authority.js': { routingProfileAuthorityBindingSchema: { parse: value => value } }, + './routing-network-native-session.js': { createRoutingNetworkNativeSession: async value => { calls++; options = value; afterCreate(); return { close() { closed++; } }; } } + }; + const exports = {}; + new Function('exports', 'require', code)(exports, name => { assert.ok(providers[name], name); return providers[name]; }); + const manifest = { servers: [1, 2], probes: [] }; + return { metadata, row, lifetime, connection, manifest, now, run: () => exports.createRoutingNetworkSessionForCurrentExecution(manifest), + revokeOrigin: () => { originAvailable = false; }, afterCreate: fn => { afterCreate = fn; }, + stats: () => ({ calls, closed, inspections, options, query }) }; +} +test('real origin supplies operation, principal, signal and minimum immutable horizon', async () => { + const f = fixture(); await f.run(); const { options, query } = f.stats(); + assert.equal(options.sourceOperationId, f.metadata.operationId); assert.equal(options.context.signal, f.lifetime.signal); + assert.equal(options.deadline, f.lifetime.expiresAt - 15000); assert.equal(options.manifest, f.manifest); + assert.deepEqual(query.values, [f.metadata.operationId, f.metadata.principal.connectionId, f.metadata.planId]); + assert.match(query.text, /o.state='running'/); assert.match(query.text, /s.state='intent'/); +}); +test('elapsed original 120 seconds cannot be renewed from current time', async () => { + const f = fixture(); f.row.created_at = new Date(f.now - 120001); await assert.rejects(f.run); assert.equal(f.stats().calls, 0); +}); +for (const [name, change] of [ + ['plan', f => { f.row.expires_at = new Date(f.now + 30000); }], + ['owner', f => { f.connection.owner_valid_until = new Date(f.now + 30000); }], + ['policy', f => { f.connection.policy.expiresAt = new Date(f.now + 30000).toISOString(); }], + ['profile', f => { f.metadata.serviceInput.persistentProfileAuthority.packet.validUntil = new Date(f.now + 30000).toISOString(); f.row.intended_state.input = structuredClone(f.metadata.serviceInput); }], + ['operation', f => { f.row.created_at = new Date(f.now - 90000); }] +]) test(`minimum ${name} expiry is conserved with publication reserve`, async () => { const f = fixture(); change(f); await f.run(); assert.equal(f.stats().options.deadline, f.now + 15000); }); +for (const [key, value] of [['approved', false], ['phase', 'recovery'], ['stepIndex', 1], ['operationName', 'operations.recover'], ['operationVersion', 6], ['serviceName', 'routing.community']]) { + test(`wrong live origin ${key} is denied before native factory`, async () => { const f = fixture(); f.metadata[key] = value; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); }); +} +test('outside verified origin cannot supply authority', async () => { const f = fixture(); f.revokeOrigin(); await assert.rejects(f.run); assert.equal(f.stats().calls, 0); }); +test('persisted intended state must match the live service input', async () => { const f = fixture(); f.row.intended_state.input = {}; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); }); +for (const field of ['evidence', 'payload']) test(`changed randomized sealed ${field} is denied before native`, async () => { const f = fixture(); f.row[field].sealed = 'changed'; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); }); +test('monotonic expiry denies open even with unexpired wall horizon', async () => { const f = fixture(); f.lifetime.monotonicDeadline = performance.now() - 1; await assert.rejects(f.run); assert.equal(f.stats().calls, 0); }); +test('late origin change closes the newly opened native owner', async () => { const f = fixture(); f.afterCreate(() => { f.metadata.planId = 'changed'; }); await assert.rejects(f.run); assert.equal(f.stats().closed, 1); }); +test('returned native authorize callback remains tied to original metadata', async () => { const f = fixture(); await f.run(); f.metadata.operationId = 'changed'; await assert.rejects(f.stats().options.authorize); }); diff --git a/backend/src/modules/mcp/routing-network-execution-session.ts b/backend/src/modules/mcp/routing-network-execution-session.ts new file mode 100644 index 00000000..7d214cf6 --- /dev/null +++ b/backend/src/modules/mcp/routing-network-execution-session.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto" +import type { QueryConfig } from "pg" +import { pool } from "../../db/index.js" +import { captureVerifiedMcpServiceExecution, inspectVerifiedMcpServiceExecution, inspectVerifiedMcpServiceExecutionLifetime } from "../../services/config-mutation-policy.js" +import { effectiveMcpConnection } from "./store.js" +import { findMcpOperation } from "./registry.js" +import { fingerprint, McpError, redact } from "./security.js" +import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js" +import { createRoutingNetworkNativeSession, type RoutingNetworkNativeManifest } from "./routing-network-native-session.js" + +/** Private producer for the existing native/configuration-round reader. The + * manifest selects reads, never identity or authority. Its exact scope, probe + * ownership and whole-session budget are still checked by the native factory. + * Only the original live verified service step can open or use this session. + * No operation/principal/deadline/authorization callback is caller supplied. + * This grants no write, recovery or authenticated log-interval coverage. */ +export async function createRoutingNetworkSessionForCurrentExecution(manifest: RoutingNetworkNativeManifest): Promise>> { + const fail = () => new McpError("STATE_CONFLICT", "Нет исходного running scoped execution для native acquisition", 409) + const origin = await captureVerifiedMcpServiceExecution() + const initial = await inspectVerifiedMcpServiceExecution(origin) + if (!initial.approved || initial.phase !== "forward" || initial.stepIndex !== 0 || + initial.operationName !== "routing.community.switch" || initial.operationVersion !== findMcpOperation(initial.operationName)?.version || + initial.serviceName !== "routing.community.scoped") throw fail() + const lifetime = await inspectVerifiedMcpServiceExecutionLifetime(origin) + const authorize = async () => { + const current = await inspectVerifiedMcpServiceExecution(origin) + if (fingerprint(current) !== fingerprint(initial)) throw fail() + } + await authorize() + const connection = await effectiveMcpConnection(initial.principal) + await authorize() + // The service lifetime may be narrower than plan expiry, but it does not + // replace the operation-created-at +120s native horizon. Never renew at now. + const query: QueryConfig & { query_timeout: number } = { text: `SELECT o.created_at,p.expires_at, + CASE WHEN octet_length(p.evidence::text)<=1048576 THEN p.evidence END AS evidence, + CASE WHEN octet_length(p.payload::text)<=1048576 THEN p.payload END AS payload, + CASE WHEN octet_length(s.intended_state::text)<=1048576 THEN s.intended_state END AS intended_state FROM mcp_operations o + JOIN mcp_plans p ON p.id=o.plan_id JOIN mcp_operation_steps s ON s.operation_id=o.id + WHERE o.id=$1 AND o.connection_id=$2 AND o.plan_id=$3 AND o.state='running' + AND s.step_index=0 AND s.state='intent' LIMIT 2`, + values: [initial.operationId, initial.principal.connectionId, initial.planId], + query_timeout: Math.max(1, Math.min(10000, lifetime.expiresAt - Date.now())) } + const source = await pool.query(query) + await authorize() + if (source.rows.length !== 1 || !(source.rows[0].created_at instanceof Date) || !(source.rows[0].expires_at instanceof Date)) throw fail() + const sha = (value: string) => createHash("sha256").update(value).digest("hex") + if (typeof source.rows[0].evidence?.sealed !== "string" || typeof source.rows[0].payload?.sealed !== "string" || + sha(source.rows[0].evidence.sealed) !== initial.sealedEvidenceProof || sha(source.rows[0].payload.sealed) !== initial.sealedPayloadProof) throw fail() + if (fingerprint(source.rows[0].intended_state) !== fingerprint(redact({ kind: "service", service: initial.serviceName, input: initial.serviceInput }))) throw fail() + const authorityExpiry = initial.serviceInput.persistentProfileAuthority === undefined ? Infinity : + Date.parse(routingProfileAuthorityBindingSchema.parse(initial.serviceInput.persistentProfileAuthority).packet.validUntil) + const deadline = Math.min(source.rows[0].created_at.getTime() + 120000, source.rows[0].expires_at.getTime(), lifetime.expiresAt, + Date.parse(initial.principal.credentialExpiresAt), connection.owner_valid_until.getTime(), Date.parse(connection.policy.expiresAt), authorityExpiry) + // Reserve publication/current-authority time for the existing manual result. + // This only narrows the original horizon; it never creates a renewed grant. + const readDeadline = deadline - 15000 + if (!Number.isFinite(readDeadline) || Date.now() >= readDeadline || lifetime.signal.aborted || performance.now() >= lifetime.monotonicDeadline) throw fail() + const session = await createRoutingNetworkNativeSession({ sourceOperationId: initial.operationId, + context: { principal: initial.principal, connection, signal: lifetime.signal }, authorize, deadline: readDeadline, manifest }) + try { await authorize(); return session } + catch (error) { session.close(); throw error } +} diff --git a/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts b/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts index d528a4dc..4f95f0c1 100644 --- a/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts +++ b/backend/src/modules/mcp/routing-network-forward-rounds.owned.test.ts @@ -15,6 +15,8 @@ assert.equal(target.search, ""); assert.equal(target.hash, "") assert(process.env.MCP_STATE_DIR && isAbsolute(process.env.MCP_STATE_DIR)) assert.equal(basename(resolve(process.env.MCP_STATE_DIR)), "state") assert(basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-")) +const originalEffects = process.env.MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS === "true" +assert(!(originalEffects && process.env.MMAPP_OWN_ROUTING_WORKER_REPRO === "true"), "Owned effect acceptance cannot mix with the worker repro profile") process.env.AUTH_REQUIRED = "true" process.env.AUTH_JWT_SECRET = "owned-routing-forward-rounds-acceptance-secret" process.env.AUTH_ISSUER = "https://owned-routing-forward-rounds.test" @@ -32,7 +34,8 @@ try { if(process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true") { console.log("Worker fixture repro:",await (await import("./routing-worker-fixture-isolation.sql-helper.js")).acceptRoutingWorkerFixtureIsolation(actor)) } - const result = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor, process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true }) + const result = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor, + originalEffects ? { originalEffectsOnly: true } : process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true"?{}:{ forwardRoundsOnly: true }) assert.equal(result.accepted, true) } finally { await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation() diff --git a/backend/src/modules/mcp/routing-network-native-session.test.ts b/backend/src/modules/mcp/routing-network-native-session.test.ts index 227d39dc..06dbd729 100644 --- a/backend/src/modules/mcp/routing-network-native-session.test.ts +++ b/backend/src/modules/mcp/routing-network-native-session.test.ts @@ -353,3 +353,21 @@ test("DNS cancellation retains uncancellable lookup capacity and a late answer c authorize: async () => { if (++checks > 1) throw new SessionError("RESET_REQUIRED", "revoked during DNS") } }), (error: { code?: string }) => error.code === "RESET_REQUIRED") assert.equal(lookups, 1) }) + + +test("complete authority fences bound repeated runtime authorization while preserving every native boundary", async () => { + const f = await fixture(), session = await f.create(f.options) + const before = f.stats() + await session.readSnapshot("before") + const after = f.stats(), native = after.nativeCalls - before.nativeCalls, callbacks = after.callbacks - before.callbacks + assert.equal(native, 6) + // Two full fences and the direct dispatch guard stay fresh for every call. + // SQL/file leaves inside those fences must not recursively multiply them. + assert(callbacks >= native * 4) + assert(callbacks <= 50, "Six native reads must fit a bounded number of complete runtime revalidations") + f.afterNative(f.revoke) + await assert.rejects(session.readSnapshot("after-forward")) + assert.equal(f.stats().nativeCalls, after.nativeCalls + 1) + await assert.rejects(session.readSnapshot("after-forward")) + assert.equal(f.stats().nativeCalls, after.nativeCalls + 1) +}) diff --git a/backend/src/modules/mcp/routing-network-native-session.ts b/backend/src/modules/mcp/routing-network-native-session.ts index 2afa3a05..cf630845 100644 --- a/backend/src/modules/mcp/routing-network-native-session.ts +++ b/backend/src/modules/mcp/routing-network-native-session.ts @@ -119,8 +119,16 @@ export async function createRoutingNetworkNativeSession(options: { try { return await work() } finally { active(); await trustedAuthorize(); active() } } + // Primitive source reads keep the immutable lifetime checks. The complete + // authority fence surrounds their batch; native dispatch retains its own + // fresh before/after authorization. Avoid recursively re-running the full + // runtime authorization around every SQL/file leaf inside that same fence. + const bounded = async (work: () => Promise) => { + active() + try { return await work() } finally { active() } + } const currentAuthority = async () => { - const current = await guarded(() => effectiveMcpConnection(principal)) + const current = await bounded(() => effectiveMcpConnection(principal)) if (current.id !== connection.id || current.owner_id !== connection.owner_id || current.generation !== connection.generation || fingerprint(current.policy) !== fingerprint(connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(connection.owner_permissions) || !hasPermission(current.owner_permissions, "mm:network:read") || !hasPermission(current.policy.permissions, "mm:network:read") || @@ -129,13 +137,14 @@ export async function createRoutingNetworkNativeSession(options: { const sourceRow = async () => { const query: QueryConfig & { query_timeout: number } = { text: "SELECT o.id,o.state,o.connection_id,o.created_at,p.id AS plan_id,p.operation_id,p.operation_version,p.approved_by,p.expires_at,s.state AS step_state,CASE WHEN octet_length(s.intended_state::text)<=1048576 THEN s.intended_state END AS intended_state,CASE WHEN octet_length(p.evidence::text)<=1048576 THEN p.evidence END AS evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id JOIN mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0 WHERE o.id=$1 AND o.connection_id=$2 LIMIT 2", values: [sourceOperationId, principal.connectionId], query_timeout: Math.max(1, Math.min(10000, deadline - Date.now())) } - const result = await guarded(() => pool.query(query)) + const result = await bounded(() => pool.query(query)) if (result.rows.length !== 1) throw fail("NOT_FOUND", "Exact source native session недоступен") const row = result.rows[0] if (!["running", "applied", "partial"].includes(row.state) || !["intent", "applied", "verified"].includes(row.step_state) || row.operation_id !== "routing.community.switch" || row.operation_version !== findMcpOperation(row.operation_id)?.version || !row.approved_by || !row.evidence?.sealed || !(row.created_at instanceof Date) || !(row.expires_at instanceof Date)) throw fail("STATE_CONFLICT", "Нет совместимого подтверждённого source intent") return row } try { + await guarded(async () => {}) await currentAuthority() const row = await sourceRow() if (row.state !== "running" || row.step_state !== "intent") throw fail("STATE_CONFLICT", "Native session требует исходный running intent") @@ -176,14 +185,14 @@ export async function createRoutingNetworkNativeSession(options: { if (endpoints.length !== fullScope.length || endpoints.some(endpoint => !endpoint.enabled) || fingerprint(endpoints.map(endpoint => endpoint.id).sort((a,b) => a-b)) !== fingerprint(fullScope)) throw fail("DEVICE_UNAVAILABLE", "Полный источник native manifest недоступен") const sources = await guarded(() => createRoutingSourceContext(endpoints)) if (fingerprint(sources.expected) !== fingerprint(input.contextSources)) throw fail("STATE_CONFLICT", "Исходный endpoint manifest изменился") - const fence = async () => { + const fence = async () => guarded(async () => { await currentAuthority() - if (await guarded(() => accessGeneration()) !== generation) throw fail("RESET_REQUIRED", "Access generation native session изменён") - await guarded(() => assertPersistentScopedProfileAuthority(input, signal)) + if (await bounded(() => accessGeneration()) !== generation) throw fail("RESET_REQUIRED", "Access generation native session изменён") + await bounded(() => assertPersistentScopedProfileAuthority(input, signal)) const current = await sourceRow() if (fingerprint({ id: current.id, createdAt: current.created_at, planId: current.plan_id, evidence: current.evidence, expiresAt: current.expires_at, intendedState: current.intended_state }) !== sourceIdentity) throw fail("STATE_CONFLICT", "Исходный native intent изменён") - await guarded(() => sources.assertCurrent()) - } + await bounded(() => sources.assertCurrent()) + }) const pins = await guarded(() => resolveRoutingNetworkDestinationPins(endpoints, { deadline, signal, authorize: fence })) await fence() const clients = routeContextClients(freeze(structuredClone(endpoints)), signal, { deadline, maxCalls: 256, maxBytes: 8 * 1048576, pins }) diff --git a/backend/src/modules/mcp/routing-network-native.sql.test.ts b/backend/src/modules/mcp/routing-network-native.sql.test.ts index 4487ba6e..ee1b2500 100644 --- a/backend/src/modules/mcp/routing-network-native.sql.test.ts +++ b/backend/src/modules/mcp/routing-network-native.sql.test.ts @@ -17,7 +17,8 @@ const { installLoopbackFixtureAgent } = await import("../../test/loopback-http-a const { mcpConnectionPolicySchema } = await import("@mmapp/contracts/mcp") const { routeAiProfileSchema } = await import("@mmapp/contracts/mcp-routing") const security = await import("./security.js") -const { effectiveMcpConnection } = await import("./store.js") +const { effectiveMcpConnection, authenticateMcpToken } = await import("./store.js") +const { invokeMcpTool } = await import("./runtime.js") const { createRoutingSourceContext, readRoutingEndpoints } = await import("./routing-observation-source.js") const { resolveRoutingProfileAuthority } = await import("./routing-profile-authority.js") await import("./actions.js") @@ -28,7 +29,7 @@ const { captureRoutingNetworkObservationAssembly } = await import("./routing-net const { readRoutingNetworkScopedCommunityContext } = await import("./routing-network-scoped-context.js") type Options = Parameters[0] -type Mode = "normal" | "zero-ping" | "redirect" | "auth" | "malformed" | "oversized" | "rows" | "total-bytes" | "total-rows" +type Mode = "topology-unknown" | "normal" | "zero-ping" | "redirect" | "auth" | "malformed" | "oversized" | "rows" | "total-bytes" | "total-rows" let mode: Mode = "normal", onRequest: (() => Promise | void) | undefined const requests: { method: string; path: string; body: Record }[] = [] let responseBytes = 0, writes = 0, sinks = 0 @@ -82,6 +83,7 @@ const router = http.createServer(async (req, res) => { assert.equal(body.address, "203.0.113.9"); assert.equal(body.interface, "wan1"); assert.equal(body.vrf, "probe"); assert.equal(body["max-hops"], "16") payload = [{ ".section": "0", address: "203.0.113.9", sent: "3", loss: "0", last: "1ms" }] } else { writes++; throw Error("Unexpected native mutation") } + if (mode === "topology-unknown" && path === "/routing/route") payload = [{ ...collections(path)[0], "immediate-gw": "17", "bgp.peer-cache-id": "*s1" }] if (mode === "rows") payload = Array.from({ length: 1001 }, () => ({ version: "7.26" })) if (mode === "total-rows" && path !== "/system/resource") payload = Array.from({ length: 1000 }, () => ({ value: "fixture" })) if (mode === "total-bytes") payload = [{ version: "7.26", padding: "x".repeat(600000) }] @@ -133,6 +135,12 @@ async function source(withProbes = true) { try { await applySqlMigrations(pool) + // Other genuine phases may have already created authenticated checkpoints in + // this owned database. Read-only acceptance must preserve every existing row + // and add none; raw SQL JSON also preserves timestamp microseconds that + // conversion to a JavaScript Date would truncate. + const checkpointBaselineRows = (await pool.query("SELECT to_jsonb(c)::text AS inventory_row FROM mcp_operation_checkpoints c ORDER BY operation_id,step_index")).rows + const checkpointBaseline = security.fingerprint(checkpointBaselineRows) assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0) await pool.query("UPDATE mcp_settings SET enabled=true WHERE id=1") serverId = Number((await pool.query("INSERT INTO servers(name,host,port,enabled,username,password,use_ssl,verify_ssl) VALUES('owned-native-reader','127.0.0.1',$1,true,'fixture','inert-only',false,true) RETURNING id", [bound.port])).rows[0].id) @@ -140,9 +148,11 @@ try { const f = await source(), originalAuthorize = f.options.authorize, session = await createRoutingNetworkNativeSession(f.options), before = responseBytes const first = await session.readSnapshot("before") assert.equal(first.securityProof, false); assert.equal(first.logCoverage, "unknown"); assert.equal(first.probes.length, 1) - const stamp = Date.now(), server = first.servers[0] + const server = first.servers[0] const manifest = { revision: 1, phase: "forward", identity: { operationId: f.operationId, stepIndex: 0, profileId: f.profileId, profileVersion: 1, sourceEpoch: "owned-fixture-epoch", authorityIdentity: "owned-fixture-authority" }, - sourceCreatedAt: stamp - 1000, deadline: f.options.deadline, observationStartAt: stamp - 500, maximumSampleAgeMs: 30000, serverIds: [serverId], + // Bind the diagnostic fixture to the actual acquisition interval. A guessed + // lookback after SQL/HTTP completes can put its source after the first read. + sourceCreatedAt: Date.parse(first.startedAt), deadline: f.options.deadline, observationStartAt: Date.parse(first.observedAt), maximumSampleAgeMs: 30000, serverIds: [serverId], configuration: { serverId, nativeId: "*r1", table: "tenant", bodyIdentity: "not-a-configuration-checkpoint", dependencyIdentity: "not-a-publication-proof" }, routes: [{ key: "route1", serverId, family: "ipv4", prefix: "203.0.113.0/24", table: "tenant", sessionKey: "peer1", nextHop: "192.0.2.1", interface: "wan1", resolverTable: "main", resolutionChain: [{ address: "192.0.2.1", table: "main", interface: "wan1" }] }], bgp: [{ key: "peer1", serverId, family: "ipv4", connectionId: "*c1", instance: "default", localAddress: "192.0.2.2", remoteAddress: "192.0.2.1", remoteAsn: 65001 }], @@ -233,9 +243,59 @@ try { const f = await source(false), session = await createRoutingNetworkNativeSession(f.options); mode = "total-rows" await session.readSnapshot("before"); await assert.rejects(session.readSnapshot("after-forward")); assert(session.readBudget().rows > 10000) }) + const topologyObserver = async () => { + const f = await source(false), { connectionId, credentialId } = f.options.context.principal + const policy = mcpConnectionPolicySchema.parse({ ...f.options.context.connection.policy, mode: "observer", permissions: ["mm:network:read"], + scope: { serverIds: [serverId!], operationIds: ["routing.topology.read"], allowGlobal: false } }) + await pool.query("UPDATE mcp_connections SET policy=$2::jsonb,owner_permissions=$3::jsonb WHERE id=$1", [connectionId, JSON.stringify(policy), JSON.stringify(["mm:network:read"])]) + const token = security.randomToken("bearer") + await pool.query("UPDATE mcp_credentials SET digest=$2 WHERE id=$1", [credentialId, security.tokenDigest(token)]) + return { principal: await authenticateMcpToken(token), connectionId } + } + await run("actual public topology bearer reader observes final gateway without operation binding or namespace joins", async () => { + const f = await topologyObserver(), count = requests.length, plans = Number((await pool.query("SELECT count(*) AS n FROM mcp_plans")).rows[0].n) + const result = await invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId }) as { routeResolution: { status: string; items: Array<{ status: string; immediateGateway: { address: string; interface: string }; semanticProof: boolean; routeOriginVerified: boolean; recursiveChainVerified: boolean }> } } + assert.equal(requests.length - count, 11) + assert.equal(result.routeResolution.status, "observed") + const route = result.routeResolution.items[0] + assert.equal(route.status, "observed"); assert.deepEqual(route.immediateGateway, { address: "192.0.2.1", interface: "wan1" }) + assert.equal(route.semanticProof, false); assert.equal(route.routeOriginVerified, false); assert.equal(route.recursiveChainVerified, false) + assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_plans")).rows[0].n), plans) + assert(!JSON.stringify(result).includes("peer-cache-id")); assert(!JSON.stringify(result).includes("bgp.session")) + }) + await run("actual public topology preserves unknown numeric gateway despite matching BGP cache/session strings", async () => { + const f = await topologyObserver(); mode = "topology-unknown" + const result = await invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId }) as { routeResolution: { items: Array<{ status: string; reason: string }> } } + assert.equal(result.routeResolution.items[0].status, "unknown") + assert.equal(result.routeResolution.items[0].reason, "unconfirmed-immediate-endpoint") + }) + await run("actual public topology bearer and server scope/disabled checks refuse before native I/O", async () => { + const f = await topologyObserver(), count = requests.length + await assert.rejects(authenticateMcpToken("not-a-token")) + await assert.rejects(invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId: serverId! + 100000 })) + await pool.query("UPDATE servers SET enabled=false WHERE id=$1", [serverId]) + await assert.rejects(invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId })) + await pool.query("UPDATE servers SET enabled=true WHERE id=$1", [serverId]) + assert.equal(requests.length, count) + }) + await run("actual public topology pause endpoint ABA and cancellation retire at first response without publication", async () => { + for (const fault of ["pause", "endpoint-aba", "cancel"] as const) { + const f = await topologyObserver(), count = requests.length, cancel = new AbortController() + onRequest = async () => { + if (fault === "pause") await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{status}','\"paused\"') WHERE id=$1", [f.connectionId]) + else if (fault === "endpoint-aba") { + await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=$1", [serverId]) + await pool.query("UPDATE servers SET verify_ssl=NOT verify_ssl WHERE id=$1", [serverId]) + } else cancel.abort() + } + await assert.rejects(invokeMcpTool(f.principal, "mm_routing_topology_get", { serverId }, cancel.signal)) + assert.equal(requests.length, count + 1) + } + }) assert.equal(writes, 0) - assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_operation_checkpoints")).rows[0].n, 0, "Read-only acceptance must not manufacture CONFIG_APPLIED or network checkpoints") - console.log("Owned routing native reader SQL/HTTP:", JSON.stringify({ cases: cases.length, names: cases, requests: requests.length, nativeWrites: writes, actualSql: true, actualHttp: true, syntheticEncryptedSourcePlans: true, genuinePrepareAccepted: false, configurationCheckpointFabricated: false, signedNetworkOutcomePublished: false, logCoverage: "unknown", reservedDispatchEnabled: false, realChr: false })) + assert.equal(security.fingerprint((await pool.query("SELECT to_jsonb(c)::text AS inventory_row FROM mcp_operation_checkpoints c ORDER BY operation_id,step_index")).rows), checkpointBaseline, + "Read-only acceptance must preserve existing checkpoints exactly and create none") + console.log("Owned routing native reader SQL/HTTP:", JSON.stringify({ cases: cases.length, names: cases, requests: requests.length, nativeWrites: writes, actualSql: true, actualHttp: true, syntheticEncryptedSourcePlans: true, genuinePrepareAccepted: false, configurationCheckpointFabricated: false, priorCheckpointsPreserved: checkpointBaselineRows.length, checkpointInventoryUnchanged: true, signedNetworkOutcomePublished: false, logCoverage: "unknown", reservedDispatchEnabled: false, realChr: false })) } finally { if (profiles.length) await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=ANY($1::uuid[])", [profiles]).catch(() => {}) if (connections.length) await pool.query("UPDATE mcp_connections SET policy=jsonb_set(policy,'{status}','\"paused\"') WHERE id=ANY($1::uuid[])", [connections]).catch(() => {}) diff --git a/backend/src/modules/mcp/routing-network-observation-assembly.test.ts b/backend/src/modules/mcp/routing-network-observation-assembly.test.ts index f4a758b2..69dd9bd0 100644 --- a/backend/src/modules/mcp/routing-network-observation-assembly.test.ts +++ b/backend/src/modules/mcp/routing-network-observation-assembly.test.ts @@ -65,6 +65,7 @@ for (const ipv6 of [false, true]) for (const inverse of [false, true]) test(`fix }) const invalid = { + "source-after-first-read": (f: ReturnType) => { f.manifest.sourceCreatedAt = Date.parse(f.snapshots[0].startedAt) + 1 }, "foreign-operation": (f: ReturnType) => { f.snapshots[1].sourceOperationId = "00000000-0000-4000-8000-000000000099" }, "duplicate-server": (f: ReturnType) => { f.snapshots[1].servers.push(f.snapshots[1].servers[0]) }, "missing-probe": (f: ReturnType) => { f.snapshots[1].probes = [] }, diff --git a/backend/src/modules/mcp/routing-original-effects-caller.test.mjs b/backend/src/modules/mcp/routing-original-effects-caller.test.mjs new file mode 100644 index 00000000..b0495230 --- /dev/null +++ b/backend/src/modules/mcp/routing-original-effects-caller.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +const require=createRequire(new URL('../../../package.json',import.meta.url)),ts=require('typescript'),{z}=require('zod'); +const code=ts.transpileModule(await readFile(new URL('./routing-community-scoped-service.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; +async function fixture(mode='prepared',failure){ + const calls=[],principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},connection={id:principal.connectionId,owner_id:'owner',generation:'generation',owner_permissions:['mm:filters:write','mm:network:read'],policy:{mode:'copilot',permissions:['mm:filters:write','mm:network:read'],scope:{serverIds:[1]},expiresAt:new Date(Date.now()+120000).toISOString()},owner_valid_until:new Date(Date.now()+120000)}; + const profile={serverId:1,family:'ipv4',table:'tenant',probeVrf:'probe',community:'65000:10',autoRecovery:false,candidates:[{id:'a',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.8',gatewayRef:{resource:'ipv4_route',id:'*r0'},controlProbe:{}},{id:'b',jumpHostId:1,exitNodeId:1,gateway:'192.0.2.9',gatewayRef:{resource:'ipv4_route',id:'*r'},controlProbe:{}}],excludedCandidateIds:[],pinnedCandidateId:null}; + const input={serverId:1,profile,candidateId:'b',principal,ownerId:'owner',policyProof:JSON.stringify(connection.policy),connectionGeneration:'generation',generation:'access',proofRevision:3,originalCandidateId:'a',nativeId:'*1',outIface:'',originalRuleCiphertext:'original',desiredRuleCiphertext:'desired',desiredTargetProof:'desired-proof',beforeRules:[],receiptId:randomUUID()}; + if(mode!=='legacy')input.originalEffectSelection=mode==='prepared'?{status:'prepared',probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe'}}:{status:'unknown',reason:'unsupported-source'}; + let wrote=false,handler,network,foreign=false,reads=0,rounds=0,closed=0,ownerClosed=false,nativeCalls=100; + const unknown=reason=>({scope:'prepared-route-fib-adjacency-control-effects',status:'unknown',reason,rounds:[],logCoverage:'unknown',semanticProof:false,forwardingVerified:false,autoRecoveryAllowed:false}); + const report={...unknown(null),status:'observed-expected',rounds:[{controlProbe:'passed'},{controlProbe:'passed'}]}; + const session={sharedContext:{},readSnapshot:async stage=>{calls.push('baseline:'+stage);return {};},readConfigurationBoundForwardSnapshot:async()=>{calls.push('round:'+(++rounds));if(failure==='round'||failure==='round-closed'){ownerClosed=failure==='round-closed';throw Error('unknown');}return {};},readBudget:()=>({deadline:Date.now()+10000,calls:nativeCalls,bytes:1000,rows:50,closed:ownerClosed}),close:()=>{closed++;ownerClosed=true;calls.push('close-session');}}; + const providers={ + 'node:crypto':{randomUUID},zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../db/index.js':{pool:{query:async sql=>({rows:String(sql).includes('FROM mcp_path_checks')?[{id:randomUUID(),connection_id:principal.connectionId,status:'passed',created_at:new Date(),evidence:{}}]:[]})}}, + '../../lib/permissions.js':{hasPermission:(list,value)=>list.includes(value)}, + '../../services/config-mutation-policy.js':{assertVerifiedConfigurationAccess:async()=>{},executeGuardedConfigurationWrite:async(_sid,_method,_path,work)=>{calls.push('guarded-intent');return work();}}, + '../../services/config-mutation-lock.js':{withApplicationMutationLock:work=>work(),withConfigMutationLock:(_ids,work)=>work()}, + './store.js':{effectiveMcpConnection:async()=>connection,audit:async()=>{}}, + './security.js':{accessGeneration:async()=>'access',decryptSecret:async x=>x,encryptSecret:async x=>x,fingerprint:JSON.stringify,McpError:Error,privateSecretFingerprint:async()=>'seal',redact:x=>x}, + './routing-community-scoped-checkpoint.js':{saveScopedConfigurationCheckpoint:async()=>{calls.push('CONFIG_APPLIED');},loadScopedConfigurationCheckpoint:async()=>({checkpointProof:'configuration'}),saveScopedNetworkCheckpoint:async(_input,_seal,check,originalEffects)=>{calls.push('save-network');network={status:check.status,networkSeal:'network',...(originalEffects?{originalEffects}:{})};},loadScopedNetworkCheckpoint:async()=>network}, + './routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>'request'},'./management-transport.js':{prepareManagementTransportBinding:async()=>({})}, + './routing-community-context.js':{readRouteCommunityContext:async()=>{reads++;return {before:{scopedExecutionAvailable:true,currentCandidateId:wrote?'b':'a',dependencyProof:'dep',sourceProofs:[{serverId:1,proof:'source'}]},ownedTarget:{nativeId:'*1',rawRow:{rule:wrote?'desired':'original'},endpoint:{},desiredTargetProof:()=> 'desired-proof'},fence:async()=>{},clients:new Map([[1,{get:async()=>[{'.id':'*r'}]}]]),captureOriginalEffectSelection:async()=>{calls.push('capture-prepared-selection');return input.originalEffectSelection;}};}}, + './routing-community-context-model.js':{candidateMatchesGateway:()=>true},'./routing-model.js':{rankRoutingCandidates:()=>profile.candidates.map(candidate=>({candidate,eligible:true}))},'./routing-observations.js':{readRoutingObservations:async()=>[]}, + './path-verification.js':{requireRecentPath:async()=>'path-check',checkCandidatePath:async()=>{calls.push('legacy-probe');return {id:randomUUID(),candidateId:'b',status:'passed',observedAt:new Date().toISOString()};}}, + './routing-community-scoped-model.js':{compileScopedCommunitySwitch:()=>({compiledBody:'desired',proposed:{community:'65000:10',gateway:'192.0.2.9',outIface:''},before:{community:'65000:10',gateway:'192.0.2.8',outIface:''}}),scopedConfigurationMatches:()=>({configurationMatchesOriginal:!wrote,configurationMatchesDesired:wrote&&!foreign})}, + './routing-community-scoped-native.js':{patchScopedCommunity:async()=>{calls.push('PATCH');wrote=true;}},'./service-registry.js':{registerMcpService:(_name,value)=>{handler=value;}}, + './routing-profile-authority.js':{assertPersistentScopedProfileAuthority:async()=>{},resolveRoutingProfileAuthority:async()=>{},routingProfileAuthorityBindingSchema:{parse:x=>x},scopedProfileAuthorityTransport:async()=>({})}, + './routing-observation-source.js':{createRoutingSourceContext:async()=>({}),readRoutingEndpoints:async()=>[]},'./routing-reservation-store.js':{createRoutingReservationStore:()=>({})}, + './routing-network-execution-session.js':{createRoutingNetworkSessionForCurrentExecution:async options=>{calls.push('open-session');assert.deepEqual(Object.keys(options).sort(),['probes','servers']);if(failure==='open')throw Error('unknown');return session;}}, + './routing-network-configuration-bridge.js':{resolveRoutingNetworkForwardRoundConfigurationContext:async()=>{calls.push('configuration-round-owner');return {};},closeRoutingNetworkForwardRoundConfigurationContext:()=>{calls.push('close-round-owner');}}, + './routing-network-scoped-context.js':{readRoutingNetworkScopedCommunityContext:async()=>{calls.push('shared-publication');if(failure==='publication')throw Error('DO_NOT_EXPOSE_TEST_DETAIL');nativeCalls+=10;return{before:{dependencyProof:'dep',targetRuleProof:'desired-proof',currentCandidateId:'b'},fence:async()=>{}};}}, + './routing-original-effects.js':{originalEffectSelectionSchema:{safeParse:x=>x?{success:true,data:x}:{success:false}},unknownOriginalEffects:unknown,originalEffectBaselineMatches:()=>failure!=='baseline',inspectOriginalEffectRounds:async()=>{calls.push('inspect-effects');if(failure==='foreign')foreign=true;return report;}} + }; + const exports={};new Function('exports','require',code)(exports,name=>{assert.ok(providers[name],name);return providers[name];});exports.registerScopedCommunityService(); + return {calls,input,run:()=>handler.execute(input,{operationId:randomUUID(),phase:'forward',stepIndex:0}),prepare:()=>exports.prepareScopedCommunitySwitch({profile,candidateId:'b'},{principal,connection}),stats:()=>({wrote,closed,reads}),network:()=>network}; +} +test('actual service consumer baselines before the single PATCH and persists separate effects after genuine CONFIG_APPLIED and manual probe',async()=>{const f=await fixture(),result=await f.run();const c=f.calls;assert(c.indexOf('baseline:before')x==='PATCH').length,1);assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.forwardingVerified,false);assert.equal(result.originalEffects.status,'observed-expected');assert.equal(result.originalEffects.budget.calls,110);assert.equal(f.stats().closed,1);assert.deepEqual(result.originalEffects,f.network().originalEffects);}); +for(const mode of ['legacy','unknown'])test(`existing ${mode} manual path does not open native effect session`,async()=>{const f=await fixture(mode),result=await f.run();assert(!f.calls.includes('open-session'));assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);}); +for(const failure of ['open','baseline','round','round-closed','publication'])test(`optional ${failure} uncertainty preserves actual manual config/control receipt with effects unknown`,async()=>{const f=await fixture('prepared',failure),result=await f.run();assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true);assert.equal(result.originalEffects.status,'unknown');assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(f.calls.includes('save-network'));}); +test('foreign configuration drift after optional effect observations still refuses publication, never repeats PATCH',async()=>{const f=await fixture('prepared','foreign');await assert.rejects(f.run);assert.equal(f.calls.filter(x=>x==='PATCH').length,1);assert(!f.calls.includes('save-network'));assert.equal(f.stats().closed,1);}); +test('revision2 remains on existing manual path even with a prepared-like field',async()=>{const f=await fixture();f.input.proofRevision=2;await f.run();assert(!f.calls.includes('open-session'));}); +test('actual preparation consumes the required real-context selection method and includes it in sealed service input',async()=>{const f=await fixture(),evidence=await f.prepare();assert(f.calls.includes('capture-prepared-selection'));assert.deepEqual(evidence.steps[0].input.originalEffectSelection,f.input.originalEffectSelection);assert.equal(evidence.steps[0].input.requestProof,'seal');assert(!f.calls.includes('PATCH'));}); +test('closed effect owner falls back to exactly the old final context read without bypassing publication guard',async()=>{const legacy=await fixture('legacy');await legacy.run();const f=await fixture('prepared','round-closed');await f.run();assert(!f.calls.includes('shared-publication'));assert.equal(f.stats().reads,legacy.stats().reads);}); + +test('optional publication error exposes only a bounded failure category', async()=>{ + const f=await fixture('prepared','publication'), result=await f.run(); + assert.equal(result.originalEffects.reason,'effect-publication-revalidation-unavailable:STATE_UNAVAILABLE'); + assert(!JSON.stringify(result).includes('DO_NOT_EXPOSE_TEST_DETAIL')); + assert.equal(result.configurationVerified,true);assert.equal(result.controlProbeVerified,true); +}); diff --git a/backend/src/modules/mcp/routing-original-effects-controller.test.mjs b/backend/src/modules/mcp/routing-original-effects-controller.test.mjs new file mode 100644 index 00000000..9e33f262 --- /dev/null +++ b/backend/src/modules/mcp/routing-original-effects-controller.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFile } from 'node:fs/promises'; +import { randomUUID, createHash } from 'node:crypto'; +import test from 'node:test'; +const require=createRequire(new URL('../../../package.json',import.meta.url)),ts=require('typescript'),{z}=require('zod'); +const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex'),sign=async(value,purpose)=>hash([purpose,value]); +const code=ts.transpileModule(await readFile(new URL('./routing-controller-baseline.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; +const effectCode=ts.transpileModule(await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,effectExports={}; +new Function('exports','require',effectCode)(effectExports,name=>{if(name==='node:net')return require('node:net');if(name==='zod')return{z};if(name==='./security.js')return{fingerprint:hash};if(name==='./routing-community-context-model.js')return{};throw Error(name);}); +const preparedValue={status:'prepared',revision:1,serverId:1,routeSelectionScope:'table-afi-community-observation',candidateId:'b',family:'ipv4',table:'tenant',community:'65000:10',expectedGateway:'192.0.2.9',expectedImmediate:{address:'198.51.100.2',interface:'ether2'},binding:{connectionId:'*c',sessionId:'*s',instance:'default',table:'tenant',vrf:'main',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',remoteAsn:65001,afi:'ip'},bfd:{applicability:'not-configured',evidence:'explicit-use-bfd-false'},routes:[{nativeId:'*r',prefix:'203.0.113.0/24',originalGateway:'192.0.2.8'}],probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe',expectedHops:['198.51.100.20']}}; +async function fixture(selection='absent',effectStatus='observed-expected',switched=true){ + const principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},profileId=randomUUID(),planId=randomUUID(),operationId=randomUUID(),grant=new Date(Date.now()+60000).toISOString(); + const profile={serverId:1,table:'tenant',probeVrf:'probe',candidates:[{id:'a',jumpHostId:1,exitNodeId:1},{id:'b',jumpHostId:1,exitNodeId:1}]},perms=['mm:network:read','mm:network:write','mm:filters:write']; + const connection={id:principal.connectionId,owner_id:'owner',owner_permissions:perms,owner_valid_until:new Date(Date.now()+120000),owner_claims_issued_at:new Date(Date.now()-1000),generation:'access',policy:{status:'active',mode:'copilot',permissions:perms,scope:{serverIds:[1],operationIds:['routing.analyze','routing.community.switch','routing.path.verify']},expiresAt:new Date(Date.now()+120000).toISOString()}}; + const context={principal,connection},sourceProofs=[{serverId:1,proof:'c'.repeat(64)}],dependency='d'.repeat(64),originalTarget='a'.repeat(64),desiredTarget='b'.repeat(64),checkpointProof='e'.repeat(64),networkSeal='f'.repeat(64),requestSeal='1'.repeat(64); + let advancing=false,nativeReads=0,row; + const input={proofRevision:3,profile,principal,ownerId:'owner',nativeId:'*1',originalCandidateId:'a',originalTargetProof:originalTarget,desiredTargetProof:desiredTarget,dependencyProof:dependency,contextSources:sourceProofs,candidateId:'b',receiptId:randomUUID(),persistentProfileAuthority:{packet:{profileId,profileVersion:1}}}; + if(selection!=='absent')input.originalEffectSelection=selection?.status==='prepared'&&Object.keys(selection).length===1?structuredClone(preparedValue):selection; + const packet={revision:2,operationId,stepIndex:0,nativeId:'*1',originalCandidateId:'a',originalTargetProof:originalTarget,dependencyProof:dependency,contextSources:sourceProofs,desiredCandidateId:'b',desiredTargetProof:desiredTarget,desiredRule:'desired'}; + const network={status:'passed',networkSeal,...(effectStatus==='missing'?{}:{originalEffects:{status:effectStatus,switchEffectObserved:switched,selectionIdentity:hash(input.originalEffectSelection??null),transition:{changedPrefixes:switched?['203.0.113.0/24']:[]}}})}; + const providers={ + './routing-main-transition.js':{},'../../db/index.js':{pool:{query:async()=>({rows:[row]})}},'./registry.js':{findMcpOperation:()=>({version:7})}, + './routing-community-scoped-request.js':{assertScopedCommunityRequest:async()=>requestSeal},'./routing-profile-authority.js':{assertPersistentScopedProfileAuthority:async()=>{},routingProfileAuthorityBindingSchema:{parse:x=>x}}, + './routing-community-scoped-checkpoint.js':{loadScopedConfigurationPacket:async()=>({packet,checkpointProof,observedAt:new Date(Date.now()-500).toISOString()}),loadScopedNetworkCheckpoint:async()=>network}, + zod:{z},'@mmapp/contracts/mcp-routing':{routeAiProfileSchema:{parse:x=>x}},'../../lib/permissions.js':{hasPermission:(list,value)=>list.includes(value)},'./store.js':{effectiveMcpConnection:async()=>connection}, + './security.js':{accessGeneration:async()=>'access',constantEqual:(a,b)=>a===b,decryptSecret:async x=>x,fingerprint:hash,redact:x=>x,McpError:Error,privateSecretFingerprint:sign}, + './routing-community-context.js':{readRouteCommunityContext:async()=>{nativeReads++;return {before:{scopedExecutionAvailable:true,currentCandidateId:advancing?'b':'a',targetRuleProof:advancing?desiredTarget:originalTarget,dependencyProof:dependency,sourceProofs,nativeRuleId:'*1'},ownedTarget:{nativeId:'*1',rawRow:{rule:advancing?'desired':'original'}},fence:async()=>{}};}}, + './routing-original-effects.js':{originalEffectSelectionSchema:effectExports.originalEffectSelectionSchema} + }; + const exports={};new Function('exports','require',code)(exports,name=>{assert.ok(providers[name],name);return providers[name];}); + const baseline=await exports.prepareRoutingControllerBaseline(profileId,1,profile,grant,context);advancing=true; + const step={kind:'service',service:'routing.community.scoped',input}; + row={id:operationId,plan_id:planId,state:'applied',step_state:'verified',operation_id:'routing.community.switch',operation_version:7,approved_by:'owner',created_at:new Date(Date.now()-1000),evidence:{sealed:JSON.stringify({steps:[step],target:{scope:'servers',serverIds:[1]}})},intended_state:step}; + row.result={nativeVerificationSeal:await sign(hash({request:requestSeal,dependency,desired:desiredTarget,candidate:'b',configuration:checkpointProof,network:networkSeal}),'route-ai-scoped-verified-v3'),configurationCheckpointProof:checkpointProof,networkCheckpointProof:networkSeal,receiptId:input.receiptId,proofRevision:3,nativeWrites:1,configurationVerified:true,postProbeStatus:'passed',controlProbeVerified:true}; + return {advance:()=>exports.advanceRoutingControllerBaseline(baseline,profileId,1,profile,grant,context,planId,operationId),nativeReads:()=>nativeReads,row,network}; +} +test('actual controller preserves old advancement only when new selection field is absent',async()=>{const f=await fixture();assert.equal((await f.advance()).packet.candidateId,'b');assert.equal(f.nativeReads(),2);}); +for(const [name,selection]of[['unknown',{status:'unknown',reason:'unsupported'}],['malformed',{}],['malformed prepared',{status:'prepared',revision:2}],['null',null],['array',[]]])test(`actual controller rejects present ${name} before desired native read`,async()=>{const f=await fixture(selection);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);assert.equal(f.row.result.configurationVerified,true);assert.equal(f.row.result.controlProbeVerified,true);}); +for(const status of ['unknown','observed-other','missing'])test(`actual controller rejects prepared selection with ${status} durable effect result`,async()=>{const f=await fixture({status:'prepared'},status);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);}); +test('actual controller rejects matched poststate without an observed selected FIB transition',async()=>{const f=await fixture({status:'prepared'},'observed-expected',false);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);}); +test('actual controller advances after durable expected selected FIB transition',async()=>{const f=await fixture({status:'prepared'});assert.equal((await f.advance()).packet.candidateId,'b');assert.equal(f.nativeReads(),2);}); +test('actual controller rejects effect vector belonging to a different prepared selection',async()=>{const f=await fixture({status:'prepared'});f.network.originalEffects.selectionIdentity='0'.repeat(64);await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);}); +test('actual controller rejects transition flag without an actual changed selected prefix',async()=>{const f=await fixture({status:'prepared'});f.network.originalEffects.transition.changedPrefixes=[];await assert.rejects(f.advance);assert.equal(f.nativeReads(),1);}); diff --git a/backend/src/modules/mcp/routing-original-effects.test.mjs b/backend/src/modules/mcp/routing-original-effects.test.mjs new file mode 100644 index 00000000..391b586b --- /dev/null +++ b/backend/src/modules/mcp/routing-original-effects.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { isIP } from 'node:net'; +import { randomUUID, createHash } from 'node:crypto'; +const require=createRequire(new URL('../../../package.json',import.meta.url)), ts=require('typescript'),{z}=require('zod'); +const source=await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8'); +const exports={}; +const gateway=value=>{const match=/^([^@%\s,]+)(?:%([^@%\s,]+))?(?:@([^@%\s,]+))?$/.exec(value);if(!match||!isIP(match[1]))throw Error();return {address:match[1],family:isIP(match[1]),interface:match[2]??null,resolverTable:match[3]??'main'};}; +const providers={ 'node:net':{isIP},'node:crypto':{randomUUID},zod:{z},'./routing-community-context-model.js':{routeGatewayContext:gateway},'./security.js':{fingerprint:value=>createHash('sha256').update(JSON.stringify(value)).digest('hex')}, + './path-verification.js':{inspectControlPing:rows=>{const row=rows.at(-1),sent=Number(row.sent),received=Number(row.received);return {confirmedFailure:sent===3&&received===0,ping:{measured:sent===3&&Number.isInteger(received)&&received>=0&&received<=3,loss:(sent-received)/sent*100}};}, + inspectTrace:(rows,destination,expected)=>({verified:Array.isArray(rows)&&rows.at(-1)?.address===destination&&expected.every(x=>rows.some(r=>r.address===x))})}}; +new Function('exports','require',ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText)(exports,name=>{assert.ok(providers[name],name);return providers[name];}); +function fixture(){ + const profile={serverId:1,family:'ipv4',table:'tenant',probeVrf:'probe',community:'65000:10',candidates:[{id:'b',wanInterface:'wan',gateway:'192.0.2.9',gatewayRef:{resource:'ipv4_route',id:'*9'},controlProbe:{targetInterface:'target'}}]}; + const binding={connectionId:'*c',instance:'default',table:'tenant',vrf:'main'}; + const connection={'.id':'*c',instance:'default','routing-table':'tenant',vrf:'main','local.address':'192.0.2.1','remote.address':'192.0.2.2','remote.as':'65001',afi:'ip',templates:'',disabled:'false','use-bfd':'false'}; + const session={...connection,'.id':'*s','local.afi':'ip','remote.afi':'ip',established:'true'}; + const route={'.id':'*r',afi:'ip4','routing-table':'tenant','dst-address':'203.0.113.0/24',bgp:'true','bgp.communities':'65000:10',gateway:'192.0.2.8',active:'true',contribution:'active',ecmp:'false','immediate-gw':'198.51.100.1%ether1'}; + const raw={connections:[connection],sessions:[session],routes:[route],candidate:{'.id':'*9','immediate-gw':'198.51.100.2%ether2'},probe:{source:'198.51.100.10',destination:'198.51.100.20',expected:['198.51.100.20']}}; + const selection=exports.prepareOriginalEffectSelection(profile,'b',binding,raw); + const sample=()=>({observedAt:new Date().toISOString(),servers:[{serverId:1,routes:{payload:[{...route,gateway:'192.0.2.9','immediate-gw':'198.51.100.2%ether2'}]},bgp:{payload:[{...session}],connectionsPayload:[{...connection}]},logs:{payload:[{topics:'route,info',message:'bounded diagnostic'}]}}],probes:[{key:'b',pingPayload:[{sent:'3',received:'3','packet-loss':'0'}],tracePayload:[{address:'198.51.100.20'}]}]}); + const baseline=sample();baseline.servers[0].routes.payload=structuredClone(raw.routes); + return {profile,binding,raw,selection,sample,baseline,prepare:()=>exports.prepareOriginalEffectSelection(profile,'b',binding,raw),inspect:samples=>exports.inspectOriginalEffectRounds(selection,samples,baseline)}; +} +test('actual captured predicate seals BGP destinations separately from static resolver ID',()=>{const f=fixture();assert.equal(f.selection.status,'prepared');assert.equal(f.selection.routes[0].nativeId,'*r');assert.notEqual(f.selection.routes[0].nativeId,f.raw.candidate['.id']);assert.equal(f.selection.binding.sessionId,'*s');assert.equal('sessionKey'in f.selection.routes[0],false);}); +test('two real effect captures can observe expected FIB/adjacency/probe while logcoverage and stronger claims stay false',async()=>{const f=fixture(),result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'observed-expected');assert.equal(result.logCoverage,'unknown');assert.equal(result.routeOriginVerified,false);assert.equal(result.recursiveChainVerified,false);assert.equal(result.forwardingVerified,false);assert.equal(result.autoRecoveryAllowed,false);assert.equal(result.rounds[0].relatedTopicRows,1);}); +for(const [name,change]of[ + ['missing affected BGP route',f=>{f.raw.routes=[];}],['ambiguous prefix',f=>{f.raw.routes.push({...f.raw.routes[0],'.id':'*r2'});}], + ['connected resolver is not affected BGP route',f=>{f.raw.routes[0].bgp='false';f.raw.routes[0].connect='true';}], + ['unresolved inheritance',f=>{f.raw.connections[0].templates='template';}],['BFD not explicitly known',f=>{delete f.raw.connections[0]['use-bfd'];}], + ['router-selected source retained as unsupported effect subset',f=>{delete f.raw.probe.source;}],['mixed session identity',f=>{f.raw.sessions.push({...f.raw.sessions[0],'.id':'*s2'});}], + ['noncanonical AFI prefix',f=>{f.raw.routes[0]['dst-address']='2001:db8::/64';}] +])test(`prepared ambiguity/unsupported remains unknown: ${name}`,()=>{const f=fixture();change(f);assert.equal(f.prepare().status,'unknown');}); +test('baseline binds saved route IDs/prefix/gateway and does not infer operation UUID',()=>{const f=fixture(),base=f.sample();base.servers[0].routes.payload=structuredClone(f.raw.routes);assert.equal(exports.originalEffectBaselineMatches(f.selection,base),true);base.servers[0].routes.payload[0]['.id']='*different';assert.equal(exports.originalEffectBaselineMatches(f.selection,base),false);}); +for(const [name,change,expected]of[ + ['unexpected immediate next-hop',s=>{s.servers[0].routes.payload[0]['immediate-gw']='198.51.100.3%ether3';},'observed-other'], + ['unchanged connected route',s=>{s.servers[0].routes.payload[0].bgp='false';s.servers[0].routes.payload[0].connect='true';},'unknown'], + ['same prefix competition',s=>{s.servers[0].routes.payload.push({...s.servers[0].routes.payload[0],'.id':'*other'});},'unknown'], + ['new community target scope',s=>{s.servers[0].routes.payload.push({...s.servers[0].routes.payload[0],'.id':'*other','dst-address':'203.0.114.0/24'});},'unknown'], + ['reconnected session ID',s=>{s.servers[0].bgp.payload[0]['.id']='*new';},'unknown'], + ['observed adjacency down',s=>{s.servers[0].bgp.payload[0].established='false';},'observed-other'], + ['BFD applicability changed',s=>{s.servers[0].bgp.connectionsPayload[0]['use-bfd']='true';},'unknown'], + ['unreachable route',s=>{s.servers[0].routes.payload[0].unreachable='true';},'unknown'], + ['terminal control loss',s=>{s.probes[0].pingPayload=[{sent:'3',received:'0','packet-loss':'100'}];},'observed-other'] +])test(`effect observation: ${name}`,async()=>{const f=fixture(),s=f.sample();change(s);const result=await f.inspect([s,structuredClone(s)]);assert.equal(result.status,expected);assert.equal(result.forwardingVerified,false);}); +test('one round is not upgraded to two',async()=>{const f=fixture();assert.equal((await f.inspect([f.sample()])).status,'unknown');}); +test('unchanged expected poststate is not labelled a switch effect',async()=>{const f=fixture();f.baseline.servers[0].routes.payload[0].gateway=f.selection.expectedGateway;f.baseline.servers[0].routes.payload[0]['immediate-gw']='198.51.100.2%ether2';f.selection.routes[0].originalGateway=f.selection.expectedGateway;const result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'unknown');assert.equal(result.switchEffectObserved,false);assert.deepEqual(result.transition.changedPrefixes,[]);assert.equal(result.reason,'poststate-matched-without-selected-fib-transition');}); +test('actual selected destination gateway/final endpoint transition is recorded separately from origin',async()=>{const f=fixture(),result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'observed-expected');assert.equal(result.switchEffectObserved,true);assert.deepEqual(result.transition.changedPrefixes,['203.0.113.0/24']);assert.equal(result.transition.baselineObservedAt,f.baseline.observedAt);assert.equal(result.routeOriginVerified,false);}); +test('logical gateway change with unchanged final forwarding endpoint is not a FIB switch',async()=>{const f=fixture();f.baseline.servers[0].routes.payload[0]['immediate-gw']='198.51.100.2%ether2';const result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'unknown');assert.equal(result.switchEffectObserved,false);assert.equal(result.routeSelectionScope,'table-afi-community-observation');assert.equal(result.transitionAttribution,'unproven');assert.deepEqual(result.transition.changedPrefixes,[]);}); diff --git a/backend/src/modules/mcp/routing-original-effects.ts b/backend/src/modules/mcp/routing-original-effects.ts new file mode 100644 index 00000000..3633a900 --- /dev/null +++ b/backend/src/modules/mcp/routing-original-effects.ts @@ -0,0 +1,174 @@ +import { isIP } from "node:net" +import { z } from "zod" +import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing" +import { routeGatewayContext } from "./routing-community-context-model.js" +import { fingerprint } from "./security.js" + +type Row = Record +const id = z.string().regex(/^\*[a-z0-9]+$/i).max(100) +const text = z.string().min(1).max(200) +const ip = text.refine(value => isIP(value) !== 0) +const flag = (value: unknown): boolean | undefined => [true, "true", "yes"].includes(value as string | boolean) ? true : [false, "false", "no"].includes(value as string | boolean) ? false : undefined +const communities = (row: Row) => Array.isArray(row["bgp.communities"]) ? row["bgp.communities"] as unknown[] : typeof row["bgp.communities"] === "string" ? row["bgp.communities"].split(",") : [] +const family = (row: Row) => ["ip", "ip4"].includes(String(row.afi)) ? "ipv4" : ["ipv6", "ip6"].includes(String(row.afi)) ? "ipv6" : null +const unique = (rows: Row[], nativeId: string) => rows.filter(row => row[".id"] === nativeId) +const tupleSchema = z.object({ connectionId: id, sessionId: id, instance: text, table: text, vrf: text, + localAddress: ip, remoteAddress: ip, remoteAsn: z.number().int().positive().max(4294967295), afi: z.enum(["ip", "ipv6"]) }).strict() +const prepared = z.object({ status: z.literal("prepared"), revision: z.literal(1), serverId: z.number().int().positive(), + routeSelectionScope: z.literal("table-afi-community-observation"), + candidateId: text, family: z.enum(["ipv4", "ipv6"]), table: text, community: text, expectedGateway: text, + expectedImmediate: z.object({ address: ip, interface: text }).strict(), binding: tupleSchema, + bfd: z.object({ applicability: z.literal("not-configured"), evidence: z.literal("explicit-use-bfd-false") }).strict(), + routes: z.array(z.object({ nativeId: id, prefix: text, originalGateway: text }).strict()).min(1).max(32), + probe: z.object({ key: text, serverId: z.number().int().positive(), destination: ip, source: ip, interface: text, probeVrf: text, + expectedHops: z.array(ip).min(1).max(8) }).strict(), +}).strict() +export const originalEffectSelectionSchema = z.union([prepared, z.object({ status: z.literal("unknown"), reason: text }).strict()]) +export type OriginalEffectSelection = z.infer +type Prepared = z.infer +const state = z.enum(["observed-expected", "observed-other", "unknown"]) +const roundSchema = z.object({ observedAt: z.string().datetime(), routes: z.array(z.object({ prefix: text, status: state }).strict()).max(32), + bgp: state, bfd: z.enum(["not-configured", "unknown"]), controlProbe: z.enum(["passed", "failed", "unknown"]), + relatedTopicRows: z.number().int().nonnegative().max(1000) }).strict() +export const originalEffectReportSchema = z.object({ scope: z.literal("prepared-route-fib-adjacency-control-effects"), status: state, + reason: text.nullable(), rounds: z.array(roundSchema).max(3), logSource: z.literal("routeros-rest-ring"), logCoverage: z.literal("unknown"), + selectionIdentity: z.string().regex(/^[a-f0-9]{64}$/).nullable(), + routeSelectionScope: z.literal("table-afi-community-observation"), transitionAttribution: z.literal("unproven"), + switchEffectObserved: z.boolean(), + transition: z.object({ baselineObservedAt: z.string().datetime(), changedPrefixes: z.array(text).max(32) }).strict().optional(), + budget: z.object({ deadline: z.number().int().positive(), calls: z.number().int().nonnegative().max(256), bytes: z.number().int().nonnegative().max(8 * 1048576), + rows: z.number().int().nonnegative().max(10000) }).strict().optional(), + semanticProof: z.literal(false), routeOriginVerified: z.literal(false), recursiveChainVerified: z.literal(false), forwardingVerified: z.literal(false), autoRecoveryAllowed: z.literal(false) }).strict() +export type OriginalEffectReport = z.infer +export const unknownOriginalEffects = (reason: string): OriginalEffectReport => ({ scope: "prepared-route-fib-adjacency-control-effects", status: "unknown", reason, + rounds: [], logSource: "routeros-rest-ring", logCoverage: "unknown", selectionIdentity: null, routeSelectionScope: "table-afi-community-observation", transitionAttribution: "unproven", switchEffectObserved: false, semanticProof: false, routeOriginVerified: false, recursiveChainVerified: false, forwardingVerified: false, autoRecoveryAllowed: false }) + +function tuple(row: Row, expected: Prepared["binding"], session: boolean) { + return row.instance === expected.instance && row["routing-table"] === expected.table && row.vrf === expected.vrf && + row["local.address"] === expected.localAddress && row["remote.address"] === expected.remoteAddress && Number(row["remote.as"]) === expected.remoteAsn && + (session ? row["local.afi"] === expected.afi && row["remote.afi"] === expected.afi : row.afi === expected.afi) +} +function selectedRows(rows: Row[], selection: Pick) { + return rows.filter(row => row["routing-table"] === selection.table && family(row) === selection.family && flag(row.bgp) === true && communities(row).includes(selection.community)) +} + +/** Called only by the real private context reader. This selects observed route + * effects by table/AFI/Community, never a route→session provenance relationship. + * The initial bounded subset requires explicit flat peer identity, explicit + * BFD-off and an already configured explicit probe source. Other manual paths + * keep their existing configuration/probe behavior, with effects unknown. */ +export function prepareOriginalEffectSelection(profile: RouteAiProfile, candidateId: string, binding: { connectionId: string; instance: string | null; vrf: string; table: string } | null, + raw: { routes: Row[]; connections: Row[]; sessions: Row[]; candidate: Row | undefined; probe: { destination: string; expected: string[]; source?: string } | undefined }): OriginalEffectSelection { + try { + if (!binding || !binding.instance) throw Error() + const candidate = profile.candidates.find(value => value.id === candidateId), probe = raw.probe + if (!candidate?.controlProbe || !probe?.source || !raw.candidate) throw Error() + const connection = unique(raw.connections, binding.connectionId) + if (connection.length !== 1 || !(connection[0].templates === "" || Array.isArray(connection[0].templates) && connection[0].templates.length === 0) || + flag(connection[0].disabled) !== false || flag(connection[0]["use-bfd"]) !== false) throw Error() + const peer = connection[0], afi = profile.family === "ipv4" ? "ip" : "ipv6" + const wanted = tupleSchema.parse({ connectionId: peer[".id"], sessionId: "*0", instance: binding.instance, table: binding.table, vrf: binding.vrf, + localAddress: peer["local.address"], remoteAddress: peer["remote.address"], remoteAsn: Number(peer["remote.as"]), afi }) + if (!tuple(peer, wanted, false)) throw Error() + const sessions = raw.sessions.filter(row => tuple(row, wanted, true)) + if (sessions.length !== 1 || flag(sessions[0].established) === undefined) throw Error() + wanted.sessionId = id.parse(sessions[0][".id"]) + const affected = selectedRows(raw.routes, profile) + if (!affected.length || affected.length > 32 || new Set(affected.map(row => row["dst-address"])).size !== affected.length || new Set(affected.map(row => row[".id"])).size !== affected.length) throw Error() + for (const row of affected) { + const [address, mask, extra] = String(row["dst-address"]).split("/") + if (extra !== undefined || isIP(address) !== (profile.family === "ipv4" ? 4 : 6) || !/^\d+$/.test(mask ?? "") || Number(mask) > (profile.family === "ipv4" ? 32 : 128)) throw Error() + routeGatewayContext(String(row.gateway)) + } + // A selected destination cannot silently choose among competing origins. + if (affected.some(row => raw.routes.filter(other => other["routing-table"] === profile.table && other["dst-address"] === row["dst-address"]).length !== 1)) throw Error() + const endpoint = candidate.gatewayRef.resource === "gre" ? { address: routeGatewayContext(candidate.gateway).address, interface: String(raw.candidate.name) } : routeGatewayContext(String(raw.candidate["immediate-gw"])) + if (!endpoint.interface) throw Error() + return prepared.parse({ status: "prepared", revision: 1, serverId: profile.serverId, candidateId, family: profile.family, table: profile.table, + routeSelectionScope: "table-afi-community-observation", + community: profile.community, expectedGateway: candidate.gateway, expectedImmediate: { address: endpoint.address, interface: endpoint.interface }, binding: wanted, + bfd: { applicability: "not-configured", evidence: "explicit-use-bfd-false" }, + routes: affected.map(row => ({ nativeId: row[".id"], prefix: row["dst-address"], originalGateway: row.gateway })).sort((a, b) => String(a.prefix).localeCompare(String(b.prefix))), + probe: { key: candidateId, serverId: profile.serverId, destination: probe.destination, source: probe.source, interface: candidate.wanInterface, + probeVrf: profile.probeVrf, expectedHops: probe.expected } }) + } catch { return { status: "unknown", reason: "prepared-effect-selection-unsupported-or-ambiguous" } } +} + +type Snapshot = { observedAt: string; servers: Array<{ serverId: number; routes: { payload: Row[] }; bgp: { payload: Row[]; connectionsPayload: Row[] }; logs: { payload: Row[] } }>; + probes: Array<{ key: string; pingPayload: Row[]; tracePayload?: Row[] }> } +export function originalEffectBaselineMatches(selection: Prepared, sample: Snapshot) { + const source = sample.servers.find(row => row.serverId === selection.serverId) + if (!source) return false + const actual = selectedRows(source.routes.payload, selection) + return actual.length === selection.routes.length && selection.routes.every(wanted => actual.filter(row => row[".id"] === wanted.nativeId && row["dst-address"] === wanted.prefix && row.gateway === wanted.originalGateway).length === 1) +} + +/** Actual bounded native captures only; all stronger causal/whole-path claims + * remain false. Related log inspection is reported without completeness. */ +export async function inspectOriginalEffectRounds(rawSelection: unknown, samples: Snapshot[], baseline: Snapshot) { + const selection = prepared.parse(rawSelection), rounds: z.infer[] = [] + if (!originalEffectBaselineMatches(selection, baseline)) return unknownOriginalEffects("prepared-effect-baseline-drift") + const { inspectControlPing, inspectTrace } = await import("./path-verification.js") + for (const sample of samples) { + const source = sample.servers.find(row => row.serverId === selection.serverId) + if (!source) return unknownOriginalEffects("effect-source-missing") + const scope = selectedRows(source.routes.payload, selection) + const scopeChanged = fingerprint(scope.map(row => row["dst-address"]).sort()) !== fingerprint(selection.routes.map(row => row.prefix).sort()) + const routes = selection.routes.map(wanted => { + const matching = source.routes.payload.filter(row => row["routing-table"] === selection.table && row["dst-address"] === wanted.prefix && family(row) === selection.family) + let status: z.infer = "unknown" + if (!scopeChanged && matching.length === 1) { + const row = matching[0] + if (flag(row.bgp) === true && flag(row.connect) !== true && flag(row.static) !== true && flag(row.active) !== undefined && row.ecmp !== undefined && flag(row.ecmp) === false && + ["disabled", "filtered", "unreachable", "blackhole", "invalid"].every(key => row[key] === undefined || flag(row[key]) === false)) { + try { + const final = routeGatewayContext(String(row["immediate-gw"])) + status = flag(row.active) === true && row.contribution === "active" && row.gateway === selection.expectedGateway && final.address === selection.expectedImmediate.address && final.interface === selection.expectedImmediate.interface ? "observed-expected" : "observed-other" + } catch { /* no final resolution evidence */ } + } + } + return { prefix: wanted.prefix, status } + }) + const connections = unique(source.bgp.connectionsPayload, selection.binding.connectionId) + const sessions = source.bgp.payload.filter(row => tuple(row, selection.binding, true)) + let bgp: z.infer = "unknown" + if (connections.length === 1 && tuple(connections[0], selection.binding, false) && flag(connections[0].disabled) === false && flag(connections[0]["use-bfd"]) === false && + sessions.length === 1 && sessions[0][".id"] === selection.binding.sessionId && flag(sessions[0].established) !== undefined) bgp = flag(sessions[0].established) ? "observed-expected" : "observed-other" + const probe = sample.probes.find(row => row.key === selection.probe.key) + let controlProbe: "passed" | "failed" | "unknown" = "unknown" + if (probe) { + const ping = inspectControlPing(z.array(z.record(z.string(), z.string())).max(256).parse(probe.pingPayload)) + if (ping.confirmedFailure) controlProbe = "failed" + else if (ping.ping.measured && ping.ping.loss === 0 && inspectTrace(probe.tracePayload, selection.probe.destination, selection.probe.expectedHops).verified) controlProbe = "passed" + } + rounds.push(roundSchema.parse({ observedAt: sample.observedAt, routes, bgp, + bfd: connections.length === 1 && flag(connections[0]["use-bfd"]) === false ? "not-configured" : "unknown", controlProbe, + relatedTopicRows: source.logs.payload.filter(row => typeof row.topics === "string" && row.topics.split(",").some(topic => ["bgp", "bfd", "route", "interface"].includes(topic))).length })) + } + if (rounds.length !== 2) return unknownOriginalEffects("two-effect-rounds-required") + const all = rounds.flatMap(round => [...round.routes.map(route => route.status), round.bgp, round.bfd === "unknown" ? "unknown" : "observed-expected", round.controlProbe === "passed" ? "observed-expected" : round.controlProbe === "failed" ? "observed-other" : "unknown"]) + const before = baseline.servers.find(row => row.serverId === selection.serverId)! + // Native IDs alone may churn; they never count as a forwarding transition. + // Require an actual selected active destination's final forwarding endpoint + // to differ from the expected post-state seen consistently in both rounds. + // A changed logical gateway resolving to the SAME endpoint is not a FIB switch. + const changedPrefixes = selection.routes.filter(wanted => { + if (!rounds.every(round => round.routes.find(row => row.prefix === wanted.prefix)?.status === "observed-expected")) return false + const row = before.routes.payload.find(row => row[".id"] === wanted.nativeId && row["dst-address"] === wanted.prefix)! + if (flag(row.bgp) !== true || flag(row.active) !== true || row.contribution !== "active" || flag(row.connect) === true || flag(row.static) === true || flag(row.ecmp) !== false || + ["disabled", "filtered", "unreachable", "blackhole", "invalid"].some(key => row[key] !== undefined && flag(row[key]) !== false)) return false + try { + const endpoint = routeGatewayContext(String(row["immediate-gw"])) + routeGatewayContext(String(row.gateway)) + if (!endpoint.interface) return false + return endpoint.address !== selection.expectedImmediate.address || endpoint.interface !== selection.expectedImmediate.interface + } catch { return false } + }).map(row => row.prefix) + const postState = all.includes("unknown") ? "unknown" : all.includes("observed-other") ? "observed-other" : "observed-expected" + return originalEffectReportSchema.parse({ ...unknownOriginalEffects(""), rounds, reason: null, + selectionIdentity: fingerprint(selection), + transition: { baselineObservedAt: baseline.observedAt, changedPrefixes }, switchEffectObserved: postState === "observed-expected" && changedPrefixes.length > 0, + status: postState === "observed-expected" && !changedPrefixes.length ? "unknown" : postState, + ...(postState === "observed-expected" && !changedPrefixes.length ? { reason: "poststate-matched-without-selected-fib-transition" } : {}) }) +} + diff --git a/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts b/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts index a0a5edec..a7125ce0 100644 --- a/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts +++ b/backend/src/modules/mcp/routing-reservation-runtime.sql.test.ts @@ -1,9 +1,23 @@ import { assertOwnedRoutingReservationSql, acceptRoutingReservationSql } from "./routing-reservation.sql-helper.js" assertOwnedRoutingReservationSql() +process.env.AUTH_REQUIRED = "true" +process.env.AUTH_JWT_SECRET = "owned-routing-mixed-writer-acceptance-secret" +process.env.AUTH_ISSUER = "https://owned-routing-mixed-writer.test" const { pool, closePool } = await import("../../db/index.js") try { const { applySqlMigrations } = await import("../../db/migrate.js") await applySqlMigrations(pool) console.log("private routing reservation runtime admission SQL acceptance:", JSON.stringify(await acceptRoutingReservationSql({ runtimeAdmission: true }))) -} finally { await closePool() } + const actor = { id: "owned-routing-mixed-writer", email: "routing-mixed@example.invalid", name: "Owned mixed writer", + apps: ["mm"], permissions: ["mm:settings:admin", "mm:mcp:admin", "mm:mcp:approve", "mm:network:read", "mm:network:write", "mm:filters:write", "mm:servers:admin"], + issuedAt: Date.now() - 1000, expiresAt: Date.now() + 900000 } + await (await import("./store.js")).updateMcpSettings({ enabled: true }, actor) + await import("./actions.js") + const mixed = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor, + { manualInverseChargeOnly: true, manualInverseChargeCase: "mixed-forward" }) + console.log("private routing mixed forward/manual inverse admission SQL acceptance:", JSON.stringify(mixed)) +} finally { + await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation() + await closePool() +} diff --git a/backend/src/routes/mcp-oauth-database-admission.test.ts b/backend/src/routes/mcp-oauth-database-admission.test.ts new file mode 100644 index 00000000..992dce92 --- /dev/null +++ b/backend/src/routes/mcp-oauth-database-admission.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import Fastify, { type FastifyInstance, type FastifyPluginAsync } from "fastify" +import { readFile } from "node:fs/promises" +import { runInNewContext } from "node:vm" +import * as crypto from "node:crypto" +import { z } from "zod" +import ts from "typescript" +import { DatabaseRequestGate } from "../services/database-request-gate.js" +import * as operation from "../services/system-database-operation.js" + +// Real Fastify + plugin and shared gate/readiness wiring; SQL is inert and +// counted. No configured pool, credentials, native process or network is opened. +async function fixture() { + async function load(file: URL, modules: Record): Promise { + const exports: Record = {} + const compiled = ts.transpileModule(await readFile(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText + runInNewContext(compiled, { exports, require: (name: string) => { assert(name in modules, `Unapproved import ${name}`); return modules[name] }, URL, URLSearchParams, Buffer, Date, Promise, Error, process: { env: {} } }) + return exports as T + } + const shared = await load<{ sharedDatabaseGate: DatabaseRequestGate; databaseSchemaReadiness: ReturnType }>(new URL("../services/shared-database-gate.ts", import.meta.url), { + "./database-request-gate.js": { DatabaseRequestGate }, "./system-database-operation.js": operation, + }) + const gate = shared.sharedDatabaseGate as DatabaseRequestGate + const readiness = shared.databaseSchemaReadiness as ReturnType + let admissions = 0, sqlCalls = 0, transactions = 0, audits = 0 + let holdClientRead: (() => Promise) | undefined + const events: string[] = [], resource = "http://localhost:8000/mcp", generation = "current-owned-generation" + const query = async (sql: string) => { + sqlCalls++; events.push(sql.startsWith("INSERT INTO mcp_credentials") ? "credential-write" : "sql") + if (sql.startsWith("SELECT id FROM mcp_oauth_clients")) { + await holdClientRead?.(); return { rows: [{ id: "owned-client" }] } + } + if (sql.startsWith("SELECT * FROM mcp_oauth_clients")) return { rows: [{ id: "owned-client", redirect_uris: ["http://localhost/callback"] }] } + if (sql.startsWith("SELECT * FROM mcp_credentials")) return { rows: [{ id: "refresh-id", client_id: "owned-client", audience: resource, expires_at: new Date(Date.now() + 600000), revoked_at: null, connection_id: "connection-id", family_id: "family-id" }] } + if (sql.startsWith("SELECT * FROM mcp_oauth_clients ORDER")) return { rows: [] } + return { rows: [], rowCount: 1 } + } + const request = gate.request.bind(gate) + Object.defineProperty(gate, "request", { value: function(run: () => Promise, cancel?: () => void) { admissions++; return request(run, cancel) } }) + class McpError extends Error { constructor(readonly code: string, message: string, readonly statusCode = 403) { super(message) } } + const oauth = await load<{ mcpOauth: FastifyPluginAsync }>(new URL("./mcp-oauth.ts", import.meta.url), { + "node:crypto": crypto, zod: { z }, "../db/index.js": { pool: { query } }, + "../services/shared-database-gate.js": shared, + "../config.js": { env: { authRequired: true, CORS_ORIGIN: "http://localhost:3000" } }, + "../plugins/auth.js": { requireAuth: async (req: { authUser?: { id: string } }) => { req.authUser = { id: "owned-admin" } } }, + "../modules/mcp/store.js": { + audit: async () => { audits++ }, getConnection: async () => ({ generation, policy: { status: "active", expiresAt: new Date(Date.now() + 600000).toISOString() }, owner_valid_until: new Date(Date.now() + 600000) }), + transaction: async (work: (db: { query: typeof query }) => Promise) => { transactions++; events.push("transaction-start"); const result = await work({ query }); events.push("transaction-settled"); return result }, + }, + "../modules/mcp/security.js": { McpError, accessGeneration: async () => generation, constantEqual: (a: string, b: string) => a === b, randomToken: (kind: string) => kind + "-owned-inert", tokenDigest: (token: string) => crypto.createHash("sha256").update(token).digest("hex") }, + "./mcp-transport.js": { mcpPublicUrl: () => new URL(resource) }, + "../modules/mcp/portal-revocation.js": { assertPortalClaimsCurrent: async () => {} }, + }) + const api = await load<{ installDatabaseRequestGate(app: FastifyInstance): void }>(new URL("../plugins/database-request-gate.ts", import.meta.url), { + "../services/shared-database-gate.js": shared, + "../services/config-mutation-policy.js": { withConfigurationActor: (_actor: string, work: () => unknown) => work() }, + }) + const app = Fastify() + api.installDatabaseRequestGate(app) + await app.register(oauth.mcpOauth) + const publicRequests = () => [ + { method: "GET" as const, url: "/oauth/authorize?" + new URLSearchParams({ client_id: "owned-client", redirect_uri: "http://localhost/callback", response_type: "code", code_challenge: "a".repeat(43), code_challenge_method: "S256", resource }).toString() }, + { method: "POST" as const, url: "/oauth/token", payload: { grant_type: "refresh_token", client_id: "owned-client", resource, refresh_token: "owned-refresh" } }, + { method: "POST" as const, url: "/oauth/revoke", payload: { token: "owned-refresh", client_id: "owned-client" } }, + ] + return { app, gate, readiness, events, publicRequests, counts: () => ({ admissions, sqlCalls, transactions, audits }), hold: (work?: () => Promise) => { holdClientRead = work } } +} +function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } +async function unavailable(f: Awaited>) { + const before = f.counts() + for (const options of f.publicRequests()) { + const response = await f.app.inject(options) + assert.equal(response.statusCode, 503); assert.deepEqual(response.json(), { error: "temporarily_unavailable" }); assert.equal(response.headers["cache-control"], "no-store") + } + assert.equal(f.counts().sqlCalls, before.sqlCalls); assert.equal(f.counts().transactions, before.transactions); assert.equal(f.counts().audits, before.audits) +} + +test("public OAuth authorize/token/revoke refuse before SQL while exclusive maintenance runs", async () => { + const f = await fixture(), entered = deferred(), release = deferred() + const maintenance = f.gate.exclusive(async () => { entered.resolve(); await release.promise }) + try { await entered.promise; await unavailable(f) } + finally { release.resolve(); await maintenance; await f.app.close() } +}) +test("in-flight actual token transaction settles before exclusive replacement begins", async () => { + const f = await fixture(), entered = deferred(), release = deferred() + f.hold(async () => { entered.resolve(); await release.promise }) + const token = f.app.inject(f.publicRequests()[1]).then(response => response) + await entered.promise + let replacements = 0 + const maintenance = f.gate.exclusive(async () => { replacements++; f.events.push("replacement"); f.readiness.beginReplacement() }) + try { + await unavailable(f); assert.equal(replacements, 0) + release.resolve(); const response = await token; assert.equal(response.statusCode, 200); assert.equal(response.json().access_token, "access-owned-inert") + await maintenance; assert.equal(replacements, 1) + assert(f.events.indexOf("transaction-settled") < f.events.indexOf("replacement")); assert.equal(f.events.filter(event => event === "credential-write").length, 2) + await unavailable(f) + } finally { release.resolve(); await token; await maintenance; await f.app.close() } +}) +test("sticky replacement refusal survives exclusive finally and only explicit repair reopens OAuth SQL", async () => { + const f = await fixture() + try { + await f.gate.exclusive(async () => { f.readiness.beginReplacement() }) + await unavailable(f) + await f.gate.exclusive(async () => {}) + await unavailable(f) + await f.gate.exclusive(async () => { f.readiness.confirmSchema() }) + const responses = [] + for (const options of f.publicRequests()) responses.push(await f.app.inject(options)) + assert.deepEqual(responses.map(response => response.statusCode), [302, 200, 200]); assert(f.counts().sqlCalls > 0) + } finally { await f.app.close() } +}) +test("both pure discovery endpoints remain200 with no SQL or admission while maintenance is sticky-closed", async () => { + const f = await fixture() + try { + f.readiness.beginReplacement(); const before = f.counts() + for (const url of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server"]) assert.equal((await f.app.inject(url)).statusCode, 200) + assert.deepEqual(f.counts(), before) + } finally { await f.app.close() } +}) +test("public OAuth and existing API admin handlers acquire exactly one gate admission", async () => { + const f = await fixture() + try { + const before = f.counts().admissions + assert.equal((await f.app.inject(f.publicRequests()[0])).statusCode, 302) + assert.equal(f.counts().admissions - before, 1) + const middle = f.counts().admissions + assert.equal((await f.app.inject("/api/mcp/oauth/clients")).statusCode, 200) + assert.equal(f.counts().admissions - middle, 1) + } finally { await f.app.close() } +}) +test("ordinary invalid OAuth input preserves its existing protocol error and no-store behavior", async () => { + const f = await fixture() + try { + const response = await f.app.inject({ method: "POST", url: "/oauth/token", payload: {} }) + assert.equal(response.statusCode, 400); assert.deepEqual(response.json(), { error: "invalid_request" }); assert.equal(response.headers["cache-control"], "no-store"); assert.equal(f.counts().sqlCalls, 0) + } finally { await f.app.close() } +}) diff --git a/backend/src/routes/mcp-oauth.ts b/backend/src/routes/mcp-oauth.ts index 3eb59d3e..ec6f326d 100644 --- a/backend/src/routes/mcp-oauth.ts +++ b/backend/src/routes/mcp-oauth.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto" import type { FastifyPluginAsync } from "fastify" import { z } from "zod" import { pool } from "../db/index.js" +import { sharedDatabaseGate } from "../services/shared-database-gate.js" import { env } from "../config.js" import { requireAuth } from "../plugins/auth.js" import { audit, getConnection, transaction } from "../modules/mcp/store.js" @@ -18,6 +19,15 @@ export function verifyPkce(verifier: string, challenge: string): boolean { return /^[A-Za-z0-9._~-]{43,128}$/.test(verifier) && constantEqual(createHash("sha256").update(verifier).digest("base64url"), challenge) } export const mcpOauth: FastifyPluginAsync = async app => { + // Public OAuth SQL is outside /api: own its full handler admission here. + // Pure discovery stays available; /api consent/admin use the API gate. + app.addHook("onRoute", options => { + if (!options.url.startsWith("/oauth/")) return + const original = options.handler + options.handler = async function (request, reply) { + return sharedDatabaseGate.request(async () => await original.call(this, request, reply)) + } + }) app.setErrorHandler((error, _request, reply) => { reply.header("Cache-Control", "no-store") if (error instanceof z.ZodError) return reply.code(400).send({ error: "invalid_request" }) diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index 7a28f68c..700265bf 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -22,7 +22,7 @@ import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing" import { revalidateMcpOwner } from "../modules/mcp/store.js" import { resourceById } from "../modules/mcp/resources.js" import { registerManagementChannel } from "../modules/mcp/management.js" -import { MAX_RESTORE_ARCHIVE_BYTES, uploadRestoreArchive, readRestoreReceipt } from "../modules/mcp/database-restore.js" +import { MAX_RESTORE_ARCHIVE_BYTES, uploadRestoreArchive, readRestoreReceipt, readRestoreReceiptAuditStatus } from "../modules/mcp/database-restore.js" import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js" import { readFile } from "node:fs/promises" import { checkMcpProtocol, type ProtocolProbe } from "../modules/mcp/readiness.js" @@ -102,6 +102,7 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options app.post("/mcp/database/archives", { bodyLimit: MAX_RESTORE_ARCHIVE_BYTES }, async request => uploadRestoreArchive(request.body as Buffer, actorOf(request.authUser))) app.get("/mcp/database/receipts/:id", async request => readRestoreReceipt(idOf(request.params), actorOf(request.authUser))) + app.get("/mcp/database/receipts/:id/audit-status", async request => readRestoreReceiptAuditStatus(idOf(request.params), actorOf(request.authUser))) app.post("/mcp/database/plan", async request => { const { connectionId, archiveRef, expectedSha256 } = z.object({ connectionId: uuid, archiveRef: uuid, expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict().parse(request.body) const actor = actorOf(request.authUser) diff --git a/backend/src/services/database-maintenance-resolution.test.ts b/backend/src/services/database-maintenance-resolution.test.ts new file mode 100644 index 00000000..04c64b91 --- /dev/null +++ b/backend/src/services/database-maintenance-resolution.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import path from 'node:path' +import {mkdtemp,mkdir,writeFile,readFile,appendFile,rm,lstat,chmod,symlink} from 'node:fs/promises' +import * as filesystem from 'node:fs/promises' +import {fileURLToPath} from 'node:url' +import {createRequire} from 'node:module' +import {randomUUID,createHash} from 'node:crypto' +import {runInNewContext} from 'node:vm' +import {parseMaintenanceJournal,evaluateMaintenanceAcceptances,runtimeDatabaseTargetSha256,MAINTENANCE_ACCEPTANCE_FORMAT,type MaintenanceAcceptance} from './database-maintenance-resolution.js' +import {inspectDatabaseMaintenanceStartup,readMaintenanceRecoveryFacts} from './database-maintenance-startup.js' +const here=path.dirname(fileURLToPath(import.meta.url)),backend=path.resolve(here,'../..'),temp=path.resolve(backend,'../tmp'),require=createRequire(import.meta.url),ts=require('typescript'),digest=(v:Buffer|string)=>createHash('sha256').update(v).digest('hex') +const target='postgresql://service:old-password@db.example.invalid:5432/managed?sslmode=require' +const record=(operationId:string,state:string)=>JSON.stringify({operationId,state,at:'2026-11-01T00:00:00.000Z'})+'\n' +const source={systemIdentifier:'1234567890',databaseOid:'12345',databaseName:'managed',serverVersionNum:180004,inRecovery:false as const} +const knownCurrent={transport:{mode:'tls' as const,endpointSha256:'f'.repeat(64)},source,schemaSha256:'a'.repeat(64),rawSchemaSha256:'b'.repeat(64),physicalSchemaSha256:'c'.repeat(64),manifestSha256:'d'.repeat(64),markersSha256:'e'.repeat(64)} +async function own(work:(root:string,uncertain:string,applied:string)=>Promise){await mkdir(temp,{recursive:true});const root=await mkdtemp(path.join(temp,'owned-maintenance-resolution-'));try{ + await mkdir(path.join(root,'restore-vault'));await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,7));const uncertain=randomUUID(),applied=randomUUID() + await writeFile(path.join(root,'database-maintenance.jsonl'),record(uncertain,'intent')+record(uncertain,'unconfirmed')+record(applied,'intent')+record(applied,'applied')) + for(const [id,state]of [[uncertain,'unknown'],[applied,'applied']]){const archiveRef=randomUUID(),at='2026-11-01T00:00:00.000Z';await writeFile(path.join(root,'restore-vault',archiveRef+'.used'),JSON.stringify({operationId:id,at}));await writeFile(path.join(root,'restore-vault',id+'.receipt.json'),JSON.stringify({operationId:id,archiveRef,state,startedAt:at,automaticRetry:false,...(state==='applied'?{finishedAt:at,accessRevoked:true,warnings:[]}:{errorCode:'RESTORE_UNCONFIRMED',finishedAt:at})}))} + await work(root,uncertain,applied) + }finally{assert(path.dirname(root)===temp&&path.basename(root).startsWith('owned-maintenance-resolution-'));await rm(root,{recursive:true,force:true})}} +export async function modeledStartup(){ + // This is deliberately a MODELED POSIX ownership adapter over actual owned + // files on Windows, not evidence that Windows/Linux protection was accepted. + const sourceText=await readFile(path.join(backend,'src/services/database-maintenance-startup.ts'),'utf8'),exports:Record={} + const modeledStat=(stat:Awaited>)=>new Proxy(stat,{get(object,key){if(key==='uid')return typeof object.uid==='bigint'?0n:0;if(key==='mode')return typeof object.mode==='bigint'?(object.mode&~0o777n)|(object.isDirectory()?0o700n:0o600n):(object.mode&~0o777)|(object.isDirectory()?0o700:0o600);const value=Reflect.get(object,key);return typeof value==='function'?value.bind(object):value}}) + const fs={...filesystem,lstat:async(...args:Parameters)=>modeledStat(await Reflect.apply(filesystem.lstat,filesystem,args)),open:async(...args:Parameters)=>{const handle=await Reflect.apply(filesystem.open,filesystem,args);return new Proxy(handle,{get(object,key){if(key==='stat')return async(...args:Parameters)=>modeledStat(await Reflect.apply(handle.stat,handle,args));const value=Reflect.get(object,key);return typeof value==='function'?value.bind(object):value}})}} + const code=ts.transpileModule(sourceText,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText + runInNewContext(code,{exports,require:(name:string)=>name==='node:fs/promises'?fs:name==='node:process'?{platform:'linux',getuid:()=>0,env:{DATABASE_URL:target}}:require(name),Buffer,TextDecoder,Date,Map,Set,Object,Error}) + return exports as unknown as {inspectDatabaseMaintenanceStartup:typeof inspectDatabaseMaintenanceStartup;readAppliedRestoreAuditSources:(root:string)=>Promise} +} +async function acceptance(root:string,previousResolutionSha256:string|null=null):Promise{const facts=await readMaintenanceRecoveryFacts(root);assert(facts.generation);return {format:MAINTENANCE_ACCEPTANCE_FORMAT,resolutionId:randomUUID(),decision:'accept-observed-current-state',historicOutcomeChanged:false,automaticRestore:false,actor:{kind:'host-operator',id:'explicit-test-operator'},approvalSha256:'1'.repeat(64),inspectionSha256:'2'.repeat(64),approvedAt:'2026-11-02T00:00:00.000Z',approvalExpiresAt:'2026-11-02T00:15:00.000Z',acceptedAt:'2026-11-02T00:01:00.000Z',runtimeTargetSha256:runtimeDatabaseTargetSha256(target),previousResolutionSha256,journalCoverage:{prefixBytes:facts.journal.length,sha256:digest(facts.journal),unresolvedOperations:facts.operations.filter(op=>op.state!=='applied')},vaultCoverage:facts.vault.evidence.filter(marker=>facts.vault.unresolvedFiles.includes(marker.file)),generationObservedSha256:digest(facts.generation),knownCurrent}} +async function publish(root:string,value:MaintenanceAcceptance){await mkdir(path.join(root,'maintenance-acceptances'),{recursive:true});const raw=Buffer.from(JSON.stringify(value));await writeFile(path.join(root,'maintenance-acceptances',value.resolutionId+'.json'),raw);return raw} + +test('explicit historical acceptance allows restart after approval expiry, preserves unknown and imports only actual applied',()=>own(async(root,uncertain,applied)=>{ + const modeled=await modeledStartup(),before=await readFile(path.join(root,'restore-vault',uncertain+'.receipt.json')) + assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') + await publish(root,await acceptance(root));const result=await modeled.inspectDatabaseMaintenanceStartup(root) + assert.equal(result.classification,'accepted-current-state');assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root,target.replace('old-password','rotated-password'))).classification,'accepted-current-state');assert.equal(result.acceptedHistoricResolutions,1);assert.equal(result.historicOutcomesChanged,false) + assert.equal(JSON.stringify((await modeled.readAppliedRestoreAuditSources(root)).map(value=>value.operationId)),JSON.stringify([applied]));assert.deepEqual(await readFile(path.join(root,'restore-vault',uncertain+'.receipt.json')),before) +})) +test('completed suffix and legitimate later schema/partition changes do not require a perpetual SQL fingerprint gate',()=>own(async root=>{ + const modeled=await modeledStartup();await publish(root,await acceptance(root));const later=randomUUID();await appendFile(path.join(root,'database-maintenance.jsonl'),record(later,'intent')+record(later,'applied')) + // Reader has no SQL pool/schema query at all. External generation can rotate + // without reviving old credentials or invalidating historical acceptance. + await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,9));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'accepted-current-state') + await appendFile(path.join(root,'database-maintenance.jsonl'),record(randomUUID(),'intent'));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') +})) +test('new explicit acceptance can cover a later uncertainty without altering old records',()=>own(async root=>{ + const modeled=await modeledStartup(),first=await publish(root,await acceptance(root));await appendFile(path.join(root,'database-maintenance.jsonl'),record(randomUUID(),'intent')) + assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required');const second=await acceptance(root,digest(first));second.acceptedAt='2026-11-02T00:02:00.000Z';await publish(root,second) + assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'accepted-current-state');assert.deepEqual(await readFile(path.join(root,'maintenance-acceptances',JSON.parse(first.toString()).resolutionId+'.json')),first) +})) +test('covered bytes drift, missing proof, forks and malformed tail never unlock startup',()=>own(async(root,uncertain)=>{ + const modeled=await modeledStartup(),value=await acceptance(root),raw=await publish(root,value),filename=path.join(root,'maintenance-acceptances',value.resolutionId+'.json'),journal=path.join(root,'database-maintenance.jsonl'),original=await readFile(journal) + for(const mutation of ['receipt','tail','record','oversized-record','fork','target','generation']){ + const receipt=path.join(root,'restore-vault',uncertain+'.receipt.json'),receiptBefore=await readFile(receipt) + if(mutation==='receipt')await writeFile(receipt,receiptBefore.toString().replace('RESTORE_UNCONFIRMED','OTHER_UNCONFIRMED')) + if(mutation==='tail')await appendFile(journal,'{"operationId":') + if(mutation==='record')await writeFile(filename,raw.subarray(0,raw.length-1));if(mutation==='oversized-record')await writeFile(filename,Buffer.alloc(1048577)) + let forkFile='';if(mutation==='fork'){const fork={...value,resolutionId:randomUUID()};forkFile=path.join(root,'maintenance-acceptances',fork.resolutionId+'.json');await writeFile(forkFile,JSON.stringify(fork))} + if(mutation==='generation')await writeFile(path.join(root,'access-generation'),Buffer.alloc(31)) + const result=await modeled.inspectDatabaseMaintenanceStartup(root,mutation==='target'?target.replace('/managed','/other'):target);assert.equal(result.classification,'inspection-required',mutation) + await writeFile(journal,original);await writeFile(filename,raw);await writeFile(receipt,receiptBefore);await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,7));if(forkFile)await rm(forkFile) + } +})) +test('credential rotation is excluded; host/database/TLS identity changes are bound',()=>{ + const expected=runtimeDatabaseTargetSha256(target);assert.equal(runtimeDatabaseTargetSha256(target.replace('old-password','new-password')),expected) + for(const changed of [target.replace('db.example','other.example'),target.replace('/managed','/different'),target.replace('sslmode=require','sslmode=disable')])assert.notEqual(runtimeDatabaseTargetSha256(changed),expected) + assert.throws(()=>runtimeDatabaseTargetSha256(target+'&unreviewed_target_option=true')) +}) +test('native Windows never treats modeled POSIX acceptance as real protected authorization',()=>own(async root=>{ + if(process.platform!=='win32')return + await publish(root,await acceptance(root));assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'inspection-required') +})) +test('acceptance never accepts torn/duplicate transition grammar or fabricated current proof',()=>own(async root=>{ + const facts=await readMaintenanceRecoveryFacts(root),recordValue=await acceptance(root),raw=Buffer.from(JSON.stringify(recordValue)) + assert.throws(()=>parseMaintenanceJournal(Buffer.from(record(randomUUID(),'applied'))));assert.throws(()=>parseMaintenanceJournal(Buffer.from('{bad}'))) + const wrong={...recordValue,knownCurrent:{...knownCurrent,source:{...source,inRecovery:true}}};assert.throws(()=>evaluateMaintenanceAcceptances([Buffer.from(JSON.stringify(wrong))],facts.journal,facts.vault.evidence,recordValue.runtimeTargetSha256)) + const empty={...recordValue,journalCoverage:{...recordValue.journalCoverage,unresolvedOperations:[]},vaultCoverage:[]};assert.throws(()=>evaluateMaintenanceAcceptances([Buffer.from(JSON.stringify(empty))],facts.journal,facts.vault.evidence,recordValue.runtimeTargetSha256));assert(evaluateMaintenanceAcceptances([raw],facts.journal,facts.vault.evidence,recordValue.runtimeTargetSha256).acceptedCoreIds.size) +})) + +test('without an acceptance file existing completed-history startup gains no new target-parser prerequisite',()=>own(async(root,uncertain,applied)=>{ + await writeFile(path.join(root,'database-maintenance.jsonl'),record(applied,'intent')+record(applied,'applied')) + const receipt=JSON.parse((await readFile(path.join(root,'restore-vault',uncertain+'.receipt.json'))).toString());await rm(path.join(root,'restore-vault',uncertain+'.receipt.json'));await rm(path.join(root,'restore-vault',receipt.archiveRef+'.used')) + assert.equal((await inspectDatabaseMaintenanceStartup(root,'postgres:///managed?host=%2Ftmp&options=existing')).classification,'completed-core-maintenance') +})) + +test('new unresolved vault marker blocks even without a new core journal record',()=>own(async root=>{ + const modeled=await modeledStartup();await publish(root,await acceptance(root));const operationId=randomUUID(),archiveRef=randomUUID(),startedAt='2026-11-03T00:00:00.000Z',file=path.join(root,'restore-vault',operationId+'.receipt.json') + await writeFile(file,JSON.stringify({operationId,archiveRef,state:'running',startedAt,automaticRetry:false}));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') + await writeFile(file,JSON.stringify({operationId,archiveRef,state:'blocked',startedAt,automaticRetry:false,errorCode:'PERMISSION_DENIED',finishedAt:startedAt}));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'accepted-current-state') + const dangling=randomUUID();await writeFile(path.join(root,'restore-vault',dangling+'.used'),JSON.stringify({operationId:randomUUID(),at:startedAt}));assert.equal((await modeled.inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') +})) + +test('native POSIX immutable fsynced acceptance is consumed by actual startup; mode and symlink drift refuse',async context=>{ + if(process.platform==='win32'){context.skip('Native POSIX filesystem acceptance must run directly on Linux; Windows model is not proof');return} + await own(async root=>{ + const {writeFile:writeProtected}=await import('../../scripts/writer-profile/operator-files.mjs'),value=await acceptance(root),directory=path.join(root,'maintenance-acceptances'),filename=path.join(directory,value.resolutionId+'.json') + await mkdir(directory,{mode:0o700});await writeProtected(filename,Buffer.from(JSON.stringify(value)),{flag:'wx',mode:0o600}) + assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'accepted-current-state') + await chmod(filename,0o644);assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'inspection-required');await chmod(filename,0o600) + const alias=path.join(directory,randomUUID()+'.json');await symlink(filename,alias);assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'inspection-required');await rm(alias) + const later=randomUUID();await appendFile(path.join(root,'database-maintenance.jsonl'),record(later,'intent')+record(later,'applied'));assert.equal((await inspectDatabaseMaintenanceStartup(root,target)).classification,'accepted-current-state') + }) +}) diff --git a/backend/src/services/database-maintenance-resolution.ts b/backend/src/services/database-maintenance-resolution.ts new file mode 100644 index 00000000..2000fc76 --- /dev/null +++ b/backend/src/services/database-maintenance-resolution.ts @@ -0,0 +1,68 @@ +/** Explicit historical acceptance, not restore success or permanent schema/data admission. */ +import {createHash} from 'node:crypto' +export const MAINTENANCE_ACCEPTANCE_FORMAT='database-maintenance-current-state-acceptance-v1' +const sha=(value:Buffer|string)=>createHash('sha256').update(value).digest('hex') +const hex=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v) +const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(v) +function fail():never{throw Error('Explicit maintenance acceptance unavailable or conflicting')} +function exact(value:unknown,keys:string[]):asserts value is Record{if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join('\0')!==keys.sort().join('\0'))fail()} +const instant=(v:unknown):v is string=>typeof v==='string'&&Number.isFinite(Date.parse(v))&&new Date(v).toISOString()===v +export function parseMaintenanceJSON(raw:Buffer):unknown{const text=new TextDecoder('utf8',{fatal:true}).decode(raw),value:unknown=JSON.parse(text);if(JSON.stringify(value)!==text.trim())fail();return value} +export interface MaintenanceOperation {operationId:string;state:'intent'|'applied'|'unconfirmed'} +export function parseMaintenanceJournal(raw:Buffer):MaintenanceOperation[]{ + if(!raw.length||raw.length>8388608)fail();const text=new TextDecoder('utf8',{fatal:true}).decode(raw);if(!text.endsWith('\n'))fail() + const lines=text.slice(0,-1).split('\n');if(lines.length>65536)fail();const operations=new Map() + for(const line of lines){if(!line||Buffer.byteLength(line)>256)fail();const value=parseMaintenanceJSON(Buffer.from(line));exact(value,['operationId','state','at']);if(!uuid(value.operationId)||!instant(value.at))fail() + if(value.state==='intent'){if(operations.has(value.operationId))fail();operations.set(value.operationId,'intent')} + else if(value.state==='applied'||value.state==='unconfirmed'){if(operations.get(value.operationId)!=='intent')fail();operations.set(value.operationId,value.state)}else fail() + } + return [...operations].map(([operationId,state])=>({operationId,state})) +} +export function runtimeDatabaseTargetSha256(raw:string):string{ + const url=new URL(raw);if(!['postgres:','postgresql:'].includes(url.protocol)||!url.hostname||url.hash)fail() + const port=Number(url.port||5432);if(!Number.isInteger(port)||port<1||port>65535)fail();const database=decodeURIComponent(url.pathname.slice(1));if(!database||/[\x00-\x1f\x7f/]/.test(database))fail() + const relevant=new Set(['sslmode','ssl','sslrootcert','sslcert','sslkey','channel_binding','gssencmode','target_session_attrs']),ignored=new Set(['user','password','application_name','connect_timeout','statement_timeout','lock_timeout']) + const tls:Record={};for(const [key,value]of url.searchParams){if(relevant.has(key)){if(Object.hasOwn(tls,key))fail();tls[key]=value}else if(!ignored.has(key))fail()} + return sha(JSON.stringify({format:'database-target-selector-v1',host:url.hostname.toLowerCase(),port,database,tls:Object.fromEntries(Object.entries(tls).sort())})) +} +export interface MaintenanceMarker {file:string;bytes:number;sha256:string} +export interface MaintenanceKnownCurrent {transport:{mode:'tls'|'local-loopback';endpointSha256:string};source:{systemIdentifier:string;databaseOid:string;databaseName:string;serverVersionNum:number;inRecovery:false};schemaSha256:string;rawSchemaSha256:string;physicalSchemaSha256:string;manifestSha256:string;markersSha256:string} +export interface MaintenanceAcceptance { + format:typeof MAINTENANCE_ACCEPTANCE_FORMAT;resolutionId:string;decision:'accept-observed-current-state';historicOutcomeChanged:false;automaticRestore:false; + actor:{kind:'host-operator';id:string};approvalSha256:string;inspectionSha256:string;approvedAt:string;approvalExpiresAt:string;acceptedAt:string; + runtimeTargetSha256:string;previousResolutionSha256:string|null;journalCoverage:{prefixBytes:number;sha256:string;unresolvedOperations:MaintenanceOperation[]};vaultCoverage:MaintenanceMarker[];generationObservedSha256:string;knownCurrent:MaintenanceKnownCurrent +} +export function validateMaintenanceKnownCurrent(value:unknown):asserts value is MaintenanceKnownCurrent{ + exact(value,['transport','source','schemaSha256','rawSchemaSha256','physicalSchemaSha256','manifestSha256','markersSha256']);if(!['schemaSha256','rawSchemaSha256','physicalSchemaSha256','manifestSha256','markersSha256'].every(key=>hex(value[key])))fail() + exact(value.transport,['mode','endpointSha256']);if(!['tls','local-loopback'].includes(String(value.transport.mode))||!hex(value.transport.endpointSha256))fail() + exact(value.source,['systemIdentifier','databaseOid','databaseName','serverVersionNum','inRecovery']);const s=value.source + if(typeof s.systemIdentifier!=='string'||!/^[1-9][0-9]{0,19}$/.test(s.systemIdentifier)||BigInt(s.systemIdentifier)>18446744073709551615n||typeof s.databaseOid!=='string'||!/^[1-9][0-9]{0,9}$/.test(s.databaseOid)||BigInt(s.databaseOid)>4294967295n||typeof s.databaseName!=='string'||!/^[^\x00-\x1f\x7f]{1,63}$/.test(s.databaseName)||!Number.isInteger(s.serverVersionNum)||Number(s.serverVersionNum)<180000||Number(s.serverVersionNum)>=190000||s.inRecovery!==false)fail() +} +export function validateMaintenanceAcceptance(value:unknown):asserts value is MaintenanceAcceptance{ + exact(value,['format','resolutionId','decision','historicOutcomeChanged','automaticRestore','actor','approvalSha256','inspectionSha256','approvedAt','approvalExpiresAt','acceptedAt','runtimeTargetSha256','previousResolutionSha256','journalCoverage','vaultCoverage','generationObservedSha256','knownCurrent']) + if(value.format!==MAINTENANCE_ACCEPTANCE_FORMAT||!uuid(value.resolutionId)||value.decision!=='accept-observed-current-state'||value.historicOutcomeChanged!==false||value.automaticRestore!==false)fail() + exact(value.actor,['kind','id']);if(value.actor.kind!=='host-operator'||typeof value.actor.id!=='string'||!/^[^\x00-\x1f\x7f]{1,160}$/.test(value.actor.id))fail() + if(!['approvalSha256','inspectionSha256','runtimeTargetSha256','generationObservedSha256'].every(key=>hex(value[key]))||(value.previousResolutionSha256!==null&&!hex(value.previousResolutionSha256)))fail() + if(!instant(value.approvedAt)||!instant(value.approvalExpiresAt)||!instant(value.acceptedAt)||Date.parse(value.approvedAt)>Date.parse(value.acceptedAt)||Date.parse(value.acceptedAt)>=Date.parse(value.approvalExpiresAt))fail() + exact(value.journalCoverage,['prefixBytes','sha256','unresolvedOperations']);const coverage=value.journalCoverage + if(!Number.isSafeInteger(coverage.prefixBytes)||Number(coverage.prefixBytes)<1||Number(coverage.prefixBytes)>8388608||!hex(coverage.sha256)||!Array.isArray(coverage.unresolvedOperations)||coverage.unresolvedOperations.length>65536)fail() + const ids=new Set();for(const operation of coverage.unresolvedOperations){exact(operation,['operationId','state']);if(!uuid(operation.operationId)||!['intent','unconfirmed'].includes(String(operation.state))||ids.has(operation.operationId))fail();ids.add(operation.operationId)} + if(!Array.isArray(value.vaultCoverage)||value.vaultCoverage.length>4096)fail();const files=new Set();for(const marker of value.vaultCoverage){exact(marker,['file','bytes','sha256']);if(typeof marker.file!=='string'||!/^([a-f0-9-]{36})\.(?:used|receipt\.json)$/.test(marker.file)||!uuid(marker.file.slice(0,36))||!Number.isSafeInteger(marker.bytes)||Number(marker.bytes)<1||Number(marker.bytes)>65536||!hex(marker.sha256)||files.has(marker.file))fail();files.add(marker.file)} + if(!ids.size&&!files.size)fail();validateMaintenanceKnownCurrent(value.knownCurrent) +} +export function evaluateMaintenanceAcceptances(rawRecords:readonly Buffer[],journal:Buffer,markers:readonly MaintenanceMarker[],targetSha256:string){ + if(rawRecords.length>64)fail();const records=new Map(),ids=new Set() + for(const raw of rawRecords){if(!raw.length||raw.length>1048576)fail();const value=parseMaintenanceJSON(raw);validateMaintenanceAcceptance(value);const digest=sha(raw);if(records.has(digest)||ids.has(value.resolutionId)||value.runtimeTargetSha256!==targetSha256)fail();records.set(digest,value);ids.add(value.resolutionId) + const prefix=journal.subarray(0,value.journalCoverage.prefixBytes);if(prefix.length!==value.journalCoverage.prefixBytes||sha(prefix)!==value.journalCoverage.sha256)fail() + const unresolved=parseMaintenanceJournal(prefix).filter(op=>op.state!=='applied');if(JSON.stringify(unresolved)!==JSON.stringify(value.journalCoverage.unresolvedOperations))fail() + for(const marker of value.vaultCoverage){if(!markers.some(actual=>JSON.stringify(actual)===JSON.stringify(marker)))fail()} + } + if(!records.size)return {headSha256:null as string|null,acceptedCoreIds:new Set(),acceptedVaultFiles:new Set(),count:0} + const parents=new Set([...records.values()].map(value=>value.previousResolutionSha256).filter((v):v is string=>v!==null)),heads=[...records.keys()].filter(key=>!parents.has(key));if(heads.length!==1)fail() + let current:string|null=heads[0],child:MaintenanceAcceptance|undefined;const visited=new Set(),acceptedCoreIds=new Set(),acceptedVaultFiles=new Set() + while(current!==null){if(visited.has(current))fail();const value=records.get(current);if(!value)fail();visited.add(current) + if(child){if(child.journalCoverage.prefixBytes!child!.vaultCoverage.some(later=>JSON.stringify(later)===JSON.stringify(marker))))fail()} + value.journalCoverage.unresolvedOperations.forEach(op=>acceptedCoreIds.add(op.operationId));value.vaultCoverage.forEach(marker=>acceptedVaultFiles.add(marker.file));child=value;current=value.previousResolutionSha256 + } + if(visited.size!==records.size)fail();return {headSha256:heads[0],acceptedCoreIds,acceptedVaultFiles,count:records.size} +} diff --git a/backend/src/services/database-maintenance-startup.test.ts b/backend/src/services/database-maintenance-startup.test.ts new file mode 100644 index 00000000..194cbbe9 --- /dev/null +++ b/backend/src/services/database-maintenance-startup.test.ts @@ -0,0 +1,168 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {mkdtemp,readFile,writeFile,appendFile,mkdir,rm,readdir,link} from 'node:fs/promises' +import * as filesystem from 'node:fs/promises' +import {randomUUID} from 'node:crypto' +import path from 'node:path' +import {fileURLToPath,pathToFileURL} from 'node:url' +import {spawnSync} from 'node:child_process' +import {createRequire} from 'node:module' +import {runInNewContext} from 'node:vm' +import {inspectDatabaseMaintenanceStartup,assertDatabaseMaintenanceStartup,DatabaseMaintenanceStartupError} from './database-maintenance-startup.js' +const testFile=fileURLToPath(import.meta.url),here=path.dirname(testFile),backend=path.resolve(here,'../..'),area=path.resolve(backend,'../tmp'),require=createRequire(import.meta.url),ts=require('typescript') +const stamp='2026-11-03T00:00:00.000Z' +// Same installed loader as the backend's owned test conventions, resolved to +// an absolute file URL rather than assuming a child's package resolution. +if(process.argv[2]==='--inspect-owned-maintenance'){ + const value=await inspectDatabaseMaintenanceStartup(process.argv[3]) + process.stdout.write(JSON.stringify(value)+'\n');process.exit(value.classification==='inspection-required'?1:0) +} +const record=(operationId:string,state:string)=>JSON.stringify({operationId,state,at:stamp})+'\n' +async function own(work:(root:string)=>Promise){ + await mkdir(area,{recursive:true});const root=await mkdtemp(path.join(area,'owned-maintenance-startup-')) + try{await work(root)}finally{assert(path.dirname(root)===area&&path.basename(root).startsWith('owned-maintenance-startup-'));await rm(root,{recursive:true,force:true})} +} +async function generation(root:string){await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,7))} +async function completed(root:string){const id=randomUUID();await generation(root);await writeFile(path.join(root,'database-maintenance.jsonl'),record(id,'intent')+record(id,'applied'));return id} +async function outer(root:string,state:string,used=true,wrongClaim=false){ + const vault=path.join(root,'restore-vault');await mkdir(vault,{recursive:true}) + const operationId=randomUUID(),archiveRef=randomUUID() + const value:Record={operationId,archiveRef,state,startedAt:stamp,automaticRetry:false} + if(state==='applied')Object.assign(value,{finishedAt:stamp,accessRevoked:true,warnings:['migrationError']}) + else if(state!=='running')Object.assign(value,{errorCode:'RESTORE_UNCONFIRMED',finishedAt:stamp}) + await writeFile(path.join(vault,operationId+'.receipt.json'),JSON.stringify(value)) + if(used)await writeFile(path.join(vault,archiveRef+'.used'),JSON.stringify({operationId:wrongClaim?randomUUID():operationId,at:stamp})) + return {vault,operationId,archiveRef} +} +function child(root:string){ + const value=spawnSync(process.execPath,['--import',pathToFileURL(require.resolve('tsx')).href,testFile,'--inspect-owned-maintenance',root],{cwd:backend,encoding:'utf8',timeout:10000}) + assert.equal(value.error,undefined);assert.equal(value.signal,null) + return {code:value.status,inspection:JSON.parse(value.stdout)} +} +test('missing external state is read-only fresh legacy; empty existing state does not create authority',()=>own(async root=>{ + const absent=path.join(root,'not-created'),before=await readdir(root) + assert.equal((await inspectDatabaseMaintenanceStartup(absent)).classification,'fresh-legacy') + assert.deepEqual(await readdir(root),before) + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'fresh-legacy') + assert.deepEqual(await readdir(root),before) +})) +test('empty journal is only before-intent when an existing external access generation is readable',()=>own(async root=>{ + await writeFile(path.join(root,'database-maintenance.jsonl'),'') + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') + await generation(root) + const result=await inspectDatabaseMaintenanceStartup(root);assert.equal(result.classification,'empty-before-intent');assert.equal(result.accessGenerationObserved,true) +})) +test('actual fresh process refuses dangling intent and unconfirmed without replay or rewriting',()=>own(async root=>{ + await generation(root);const file=path.join(root,'database-maintenance.jsonl'),id=randomUUID() + await writeFile(file,record(id,'intent')) + const before=await readFile(file) + const first=child(root);assert.equal(first.code,1);assert.equal(first.inspection.reason,'UNFINISHED_MAINTENANCE') + assert.deepEqual(await readFile(file),before) + await appendFile(file,record(id,'unconfirmed')) + const unknown=child(root);assert.equal(unknown.code,1);assert.equal(unknown.inspection.reason,'UNCONFIRMED_MAINTENANCE') + assert.equal(unknown.inspection.automaticRestore,false) +})) +test('core applied allows ordinary bootstrap but never infers outer success, audit import or repaired schema',()=>own(async root=>{ + await completed(root) + const result=child(root);assert.equal(result.code,0);assert.equal(result.inspection.classification,'completed-core-maintenance') + assert.equal(result.inspection.outerOperationInferred,false);assert.equal(result.inspection.auditImportStatus,'not-verified');assert.equal(result.inspection.postRepairSchemaStatus,'not-inspected') +})) +for(const state of ['running','unknown'])test('core applied cannot promote outer '+state+' receipt to completed',()=>own(async root=>{ + await completed(root);await outer(root,state) + const result=await inspectDatabaseMaintenanceStartup(root);assert.equal(result.classification,'inspection-required');assert.equal(result.reason,'EXTERNAL_OPERATION_UNRESOLVED') +})) +test('outer applied receipt requires exact matching persisted used claim',()=>own(async root=>{ + await completed(root);const f=await outer(root,'applied',true) + assert.equal((await inspectDatabaseMaintenanceStartup(root)).outerAppliedReceipts,1) + await writeFile(path.join(f.vault,f.archiveRef+'.used'),JSON.stringify({operationId:randomUUID(),at:stamp})) + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') +})) +test('blocked predispatch operation may share an archive later successfully consumed by a different operation',()=>own(async root=>{ + await completed(root);const a=await outer(root,'blocked',false),operationB=randomUUID() + await writeFile(path.join(a.vault,operationB+'.receipt.json'),JSON.stringify({operationId:operationB,archiveRef:a.archiveRef,state:'applied',startedAt:stamp,automaticRetry:false,finishedAt:stamp,accessRevoked:true,warnings:[]})) + await writeFile(path.join(a.vault,a.archiveRef+'.used'),JSON.stringify({operationId:operationB,at:stamp})) + const result=await inspectDatabaseMaintenanceStartup(root) + assert.equal(result.classification,'completed-core-maintenance');assert.equal(result.blockedReceipts,1);assert.equal(result.outerAppliedReceipts,1) + await writeFile(path.join(a.vault,a.archiveRef+'.used'),JSON.stringify({operationId:a.operationId,at:stamp})) + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') +})) +test('missing journal with attempt markers is not a new clean install',()=>own(async root=>{ + await outer(root,'applied',true) + const value=await inspectDatabaseMaintenanceStartup(root);assert.equal(value.classification,'inspection-required');assert.equal(value.reason,'JOURNAL_ABSENT_WITH_ATTEMPT_MARKERS') +})) +test('orphan used claim, unknown publication temporary file and blocked-with-used refuse',()=>own(async root=>{ + await completed(root);const f=await outer(root,'blocked',true) + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') + await rm(path.join(f.vault,f.operationId+'.receipt.json')) + assert.equal((await inspectDatabaseMaintenanceStartup(root)).reason,'EXTERNAL_OPERATION_UNRESOLVED') + await rm(path.join(f.vault,f.archiveRef+'.used'));await writeFile(path.join(f.vault,'receipt-publication.tmp'),'partial') + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') +})) +test('malformed tails, duplicates, unknown fields/states and impossible transitions never admit startup',()=>own(async root=>{ + await generation(root);const id=randomUUID(),good=record(id,'intent') + const bad=[good.slice(0,-1),good+'{',good+record(id,'intent'),good+record(id,'applied')+record(id,'applied'),record(id,'applied'),good+record(id,'future'),JSON.stringify({operationId:id,state:'intent',at:stamp,ready:true})+'\n',`{"operationId":"${id}","state":"intent","state":"applied","at":"${stamp}"}\n`] + for(const content of bad){await writeFile(path.join(root,'database-maintenance.jsonl'),content);assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required')} +})) +test('journal byte bound refuses without accepting a truncated history',()=>own(async root=>{ + await generation(root);const file=path.join(root,'database-maintenance.jsonl') + await writeFile(file,Buffer.alloc(8*1024*1024+1,32));assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') +})) +test('hardlinked external files are not accepted as complete history',async t=>own(async root=>{ + await generation(root);const file=path.join(root,'database-maintenance.jsonl') + await writeFile(file,record(randomUUID(),'intent')) + try{await link(file,path.join(root,'other-link'))}catch(error){if((error as NodeJS.ErrnoException).code==='EPERM'){t.skip('Host filesystem policy refuses creating an owned hardlink');return}throw error} + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'inspection-required') +})) +test('actual patched bootstrap issues no SQL, migration or consumer work before recovery guard',()=>own(async root=>{ + await generation(root);await writeFile(path.join(root,'database-maintenance.jsonl'),record(randomUUID(),'intent')) + const source=await readFile(path.join(backend,'src/db/bootstrap.ts'),'utf8'),events:string[]=[],exports:Record={} + const query=async()=>{events.push('sql');return {rows:[{sqlite_imported_at:'done'}]}},client={query,release:()=>{}} + const modules:Record={ + './index.js':{pool:{query,connect:async()=>{events.push('connect');return client}}}, + './migrate.js':{applySqlMigrations:async()=>{events.push('migrations')}}, + './partitions.js':{dropExpiredPartitions:async()=>{events.push('drop')},ensurePartitionsAround:async()=>{events.push('partitions')}}, + './sqlite-import.js':{shouldImportSqlite:async()=>false,sqliteFileLooksPresent:()=>false,importSqliteToPostgres:async()=>{events.push('import')}}, + '../config.js':{env:{DATABASE_PATH:'synthetic-only'}},'../modules/mcp/security.js':{mcpStateDirectory:()=>root}, + '../services/database-maintenance-startup.js':{assertDatabaseMaintenanceStartup:async(directory:string)=>{events.push('guard');return assertDatabaseMaintenanceStartup(directory)}}, + '../services/database-restore-audit.js':{reconcileDatabaseRestoreAudits:async()=>{events.push('audit-reconcile')}}, + } + const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText + runInNewContext(code,{exports,require:(name:string)=>{assert(name in modules);return modules[name]},console}) + const initialize=exports.initDatabase as ()=>Promise + await assert.rejects(initialize(),error=>error instanceof DatabaseMaintenanceStartupError) + assert.deepEqual(events,['guard']) + await writeFile(path.join(root,'database-maintenance.jsonl'),'');events.length=0 + await initialize();assert.equal(events[0],'guard');assert(events.includes('sql')&&events.includes('migrations')) + const main=await readFile(path.join(backend,'src/index.ts'),'utf8') + const start=main.indexOf('if (isMain)');assert(start>=0) + const body=main.slice(start);assert(body.indexOf('await assertDatabaseMaintenanceStartup')own(async root=>{ + await generation(root);const operation=randomUUID(),file=path.join(root,'database-maintenance.jsonl') + await writeFile(file,record(operation,'intent')) + const source=await readFile(path.join(backend,'src/services/database-maintenance-startup.ts'),'utf8'),exports:Record={};let changed=false + const model={...filesystem,lstat:async(...args:Parameters)=>{ + const value=await Reflect.apply(filesystem.lstat,filesystem,args) + if(!changed&&args[0]===file){changed=true;await appendFile(file,record(operation,'applied'))} + return value + }} + const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText + runInNewContext(code,{exports,require:(name:string)=>name==='node:fs/promises'?model:require(name),Buffer,TextDecoder,Date,Map,Set,Object,Error}) + const inspect=exports.inspectDatabaseMaintenanceStartup as typeof inspectDatabaseMaintenanceStartup + const value=await inspect(root);assert.equal(changed,true);assert.equal(value.classification,'inspection-required') + assert.equal(value.reason,'EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED') + assert.equal((await inspectDatabaseMaintenanceStartup(root)).classification,'completed-core-maintenance','a later distinct stable inspection may observe actual terminal bytes without rewriting them') +})) +test('existing non-directory or unverifiable ancestor never becomes a missing clean state',()=>own(async root=>{ + await writeFile(path.join(root,'ordinary-file'),'not-directory') + assert.equal((await inspectDatabaseMaintenanceStartup(path.join(root,'ordinary-file','state'))).classification,'inspection-required') + const source=await readFile(path.join(backend,'src/services/database-maintenance-startup.ts'),'utf8'),exports:Record={} + const model={...filesystem,realpath:async(file:Parameters[0])=>{ + if(file===root)throw Object.assign(Error('Synthetic ancestor became unverifiable'),{code:'ENOENT'}) + return filesystem.realpath(file) + }} + const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText + runInNewContext(code,{exports,require:(name:string)=>name==='node:fs/promises'?model:require(name),Buffer,TextDecoder,Date,Map,Set,Object,Error}) + const inspect=exports.inspectDatabaseMaintenanceStartup as typeof inspectDatabaseMaintenanceStartup + assert.equal((await inspect(path.join(root,'not-created-state'))).classification,'inspection-required') +})) diff --git a/backend/src/services/database-maintenance-startup.ts b/backend/src/services/database-maintenance-startup.ts new file mode 100644 index 00000000..2d7197e8 --- /dev/null +++ b/backend/src/services/database-maintenance-startup.ts @@ -0,0 +1,181 @@ +/** Read-only classification of the EXISTING external maintenance grammar. + * No restore, journal rewrite, generation rotation, SQL, role issuer or READY. */ +import process from 'node:process' +import {constants,type BigIntStats} from 'node:fs' +import {lstat,open,readdir,realpath} from 'node:fs/promises' +import path from 'node:path' +import {createHash} from 'node:crypto' +import {parseMaintenanceJournal,evaluateMaintenanceAcceptances,runtimeDatabaseTargetSha256,type MaintenanceMarker} from './database-maintenance-resolution.js' + +const MAX_JOURNAL_BYTES=8*1024*1024,MAX_VAULT_ENTRIES=4096,MAX_RECEIPT_BYTES=65536 +const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ +type Reason='NONE'|'UNFINISHED_MAINTENANCE'|'UNCONFIRMED_MAINTENANCE'|'EXTERNAL_OPERATION_UNRESOLVED'|'JOURNAL_ABSENT_WITH_ATTEMPT_MARKERS'|'EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED' +export interface MaintenanceStartupInspection { + readonly classification:'fresh-legacy'|'empty-before-intent'|'completed-core-maintenance'|'accepted-current-state'|'inspection-required' + readonly reason:Reason + readonly journalOperations:number + readonly coreApplied:number + readonly outerAppliedReceipts:number + readonly blockedReceipts:number + readonly accessGenerationObserved:boolean + readonly outerOperationInferred:false + readonly auditImportStatus:'not-verified' + readonly postRepairSchemaStatus:'not-inspected' + readonly acceptedHistoricResolutions:number + readonly historicOutcomesChanged:false + readonly automaticRestore:false +} +class Refusal extends Error {constructor(readonly reason:Reason='EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED'){super('External maintenance state requires inspection')}} +function refuse(reason?:Reason):never{throw new Refusal(reason)} +function plain(value:unknown):value is Record{return value!==null&&typeof value==='object'&&!Array.isArray(value)} +function exact(value:unknown,keys:readonly string[]):asserts value is Record{if(!plain(value)||Object.keys(value).sort().join('\0')!==[...keys].sort().join('\0'))refuse()} +function id(value:unknown):asserts value is string{if(typeof value!=='string'||!uuid.test(value))refuse()} +function instant(value:unknown):asserts value is string{if(typeof value!=='string'||!Number.isFinite(Date.parse(value))||new Date(value).toISOString()!==value)refuse()} +function json(raw:Buffer):unknown{ + const text=new TextDecoder('utf8',{fatal:true}).decode(raw) + // All existing writers produce JSON.stringify output. Exact roundtrip also + // refuses duplicate keys and ambiguous/trailing non-JSON representations. + const value:unknown=JSON.parse(text) + if(JSON.stringify(value)!==text.trim())refuse() + return value +} +function identity(s:BigIntStats){return [s.dev,s.ino,s.size,s.mode,s.nlink,s.mtimeNs,s.ctimeNs].map(String).join(':')} +async function directory(file:string,optional=false){ + let s + try{s=await lstat(file,{bigint:true})} + catch(error){if(optional&&(error as NodeJS.ErrnoException).code==='ENOENT')return null;throw error} + if(!s.isDirectory()||s.isSymbolicLink())refuse() + // Only initial lstat absence is a new/legacy absence. An existing ancestor + // whose realpath disappears or cannot be verified is ambiguous, not fresh. + try{if(await realpath(file)!==file)refuse()}catch{refuse()} + return identity(s) +} +async function existingAncestors(file:string){ + for(let current=path.dirname(file);;current=path.dirname(current)){ + await directory(current,true) + if(current===path.dirname(current))break + } +} +async function bounded(file:string,maximum:number,optional=false){ + let before + try{before=await lstat(file,{bigint:true})}catch(error){if(optional&&(error as NodeJS.ErrnoException).code==='ENOENT')return null;throw error} + if(!before.isFile()||before.isSymbolicLink()||before.nlink!==1n||before.size>BigInt(maximum)||await realpath(file)!==file)refuse() + const signature=(s:typeof before)=>[s.dev,s.ino,s.size,s.mode,s.nlink,s.mtimeNs,s.ctimeNs].map(String).join(':') + const handle=await open(file,constants.O_RDONLY|(constants.O_NOFOLLOW??0)) + try{ + const current=await handle.stat({bigint:true});if(signature(current)!==signature(before))refuse() + const data=Buffer.alloc(Number(current.size)+1);let size=0 + while(size>} +interface VaultFacts {markers:number;applied:number;blocked:number;unresolved:boolean;auditSources:readonly AppliedRestoreAuditSource[];evidence:readonly MaintenanceMarker[];unresolvedFiles:readonly string[]} +async function vaultFacts(stateDirectory:string):Promise{ + const root=path.join(stateDirectory,'restore-vault'),before=await directory(root,true) + if(before===null)return {markers:0,applied:0,blocked:0,unresolved:false,auditSources:[],evidence:[],unresolvedFiles:[]} + const auditSources:AppliedRestoreAuditSource[]=[],evidence:MaintenanceMarker[]=[],unresolvedFiles=new Set() + const names=(await readdir(root)).sort();if(names.length>MAX_VAULT_ENTRIES)refuse() + const signatures=new Map(),used=new Map(),receipts=new Map() + for(const name of names){ + const match=/^([a-f0-9-]{36})\.(used|receipt\.json|archive\.json|archive\.bin)$/.exec(name) + if(!match)refuse();id(match[1]) + const file=path.join(root,name),stat=await lstat(file,{bigint:true});if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1n||await realpath(file)!==file)refuse() + signatures.set(name,identity(stat)) + if(match[2]==='used'){ + const raw=(await bounded(file,4096))!;evidence.push({file:name,bytes:raw.length,sha256:createHash('sha256').update(raw).digest('hex')});const value=json(raw);exact(value,['operationId','at']);id(value.operationId);instant(value.at) + used.set(match[1],{operationId:value.operationId}) + }else if(match[2]==='receipt.json'){ + const raw=(await bounded(file,MAX_RECEIPT_BYTES))!,value=json(raw);evidence.push({file:name,bytes:raw.length,sha256:createHash('sha256').update(raw).digest('hex')}) + if(!plain(value)||!['running','applied','unknown','blocked'].includes(String(value.state)))refuse() + const fields=['operationId','archiveRef','state','startedAt','automaticRetry'] + if(value.state==='applied')fields.push('finishedAt','accessRevoked','warnings') + else if(value.state!=='running')fields.push('errorCode','finishedAt') + exact(value,fields);id(value.operationId);id(value.archiveRef);instant(value.startedAt) + if(value.operationId!==match[1]||value.automaticRetry!==false)refuse() + if(value.state!=='running')instant(value.finishedAt) + if(value.state==='applied'){ + const allowed=new Set(['listenerError','schedulerError','cleanupError','migrationError','maintenanceError']) + if(value.accessRevoked!==true||!Array.isArray(value.warnings)||value.warnings.length>5||value.warnings.some(w=>typeof w!=='string'||!allowed.has(w))||new Set(value.warnings).size!==value.warnings.length)refuse() + auditSources.push(Object.freeze({operationId:value.operationId,archiveRef:value.archiveRef,finishedAt:value.finishedAt as string,receiptSha256:createHash('sha256').update(raw).digest('hex'),receipt:Object.freeze({...value})})) + }else if(value.state!=='running'&&(typeof value.errorCode!=='string'||!/^[A-Z][A-Z0-9_]{0,79}$/.test(value.errorCode)))refuse() + receipts.set(match[1],{state:String(value.state),archiveRef:value.archiveRef}) + } + } + let applied=0,blocked=0,unresolved=false + for(const [operationId,receipt]of receipts){ + const claim=used.get(receipt.archiveRef) + if(receipt.state==='applied'){if(!claim||claim.operationId!==operationId)refuse();applied++} + else if(receipt.state==='blocked'){if(claim?.operationId===operationId)refuse();blocked++} + else{unresolved=true;unresolvedFiles.add(operationId+'.receipt.json')} + } + for(const [archiveRef,claim]of used){const receipt=receipts.get(claim.operationId);if(!receipt||receipt.archiveRef!==archiveRef||receipt.state!=='applied'){unresolved=true;unresolvedFiles.add(archiveRef+'.used')}} + if(await directory(root)!==before||(await readdir(root)).sort().join('\0')!==[...names].sort().join('\0'))refuse() + for(const [name,signature]of signatures)if(identity(await lstat(path.join(root,name),{bigint:true}))!==signature)refuse() + return {markers:used.size+receipts.size,applied,blocked,unresolved,auditSources:Object.freeze(auditSources),evidence:Object.freeze(evidence),unresolvedFiles:Object.freeze([...unresolvedFiles].sort())} +} +/** Strict inspection facts; no acceptance decision and no SQL. Operator commands + * use the same grammar, not a looser recovery parser. Existing single managed + * host/operator assumption applies; bounded scans are not a concurrent fence. */ +export async function readMaintenanceRecoveryFacts(rawDirectory:string){ + if(typeof rawDirectory!=='string'||!path.isAbsolute(rawDirectory)||path.resolve(rawDirectory)!==rawDirectory)refuse() + await existingAncestors(rawDirectory);const stateBefore=await directory(rawDirectory,true) + if(stateBefore===null)return {kind:'fresh-legacy' as const,journal:Buffer.alloc(0),operations:[],vault:{markers:0,applied:0,blocked:0,unresolved:false,auditSources:[],evidence:[],unresolvedFiles:[]} as VaultFacts,generation:null} + const journalFile=path.join(rawDirectory,'database-maintenance.jsonl'),raw=await bounded(journalFile,MAX_JOURNAL_BYTES,true),vault=await vaultFacts(rawDirectory) + if(await directory(rawDirectory)!==stateBefore)refuse() + if(raw===null||raw.length===0){if(vault.markers)refuse('JOURNAL_ABSENT_WITH_ATTEMPT_MARKERS');if(raw===null)return {kind:'fresh-legacy' as const,journal:Buffer.alloc(0),operations:[],vault,generation:null} + const generation=await bounded(path.join(rawDirectory,'access-generation'),32);if(generation?.length!==32)refuse();return {kind:'empty-before-intent' as const,journal:raw,operations:[],vault,generation} + } + const generation=await bounded(path.join(rawDirectory,'access-generation'),32);if(generation?.length!==32)refuse();const operations=parseMaintenanceJournal(raw) + if(!(await bounded(journalFile,MAX_JOURNAL_BYTES))!.equals(raw)||await directory(rawDirectory)!==stateBefore)refuse() + return {kind:'journal' as const,journal:raw,operations,vault,generation} +} +/** Acceptance grants startup admission, so POSIX ownership/modes are verified. + * Native Windows protection has no implementation and refuses, never modeled. */ +export async function readMaintenanceAcceptanceRecords(stateDirectory:string):Promise{ + const root=path.join(stateDirectory,'maintenance-acceptances'),before=await directory(root,true);if(before===null)return [] + if(process.platform==='win32'||typeof process.getuid!=='function')refuse() + const uid=BigInt(process.getuid()),protectedStat=async(file:string,isDirectory:boolean)=>{const s=await lstat(file,{bigint:true});if(s.uid!==uid||(s.mode&0o077n)!==0n||(isDirectory?!s.isDirectory():!s.isFile()))refuse()} + await protectedStat(root,true) + for(let current=path.dirname(root);;current=path.dirname(current)){const stat=await lstat(current,{bigint:true});if(stat.isSymbolicLink()||!stat.isDirectory()||(stat.uid!==uid&&stat.uid!==0n)||((stat.mode&0o022n)!==0n&&!(stat.uid===0n&&(stat.mode&0o1000n)!==0n)))refuse();if(current===path.dirname(current))break} + const names=(await readdir(root)).sort();if(names.length>64)refuse();const records:Buffer[]=[] + for(const name of names){if(!/^[a-f0-9-]{36}\.json$/.test(name))refuse();id(name.slice(0,36));const file=path.join(root,name);await protectedStat(file,false);const raw=(await bounded(file,1048576))!,value=json(raw);if(!plain(value)||value.resolutionId!==name.slice(0,36))refuse();records.push(raw)} + if(await directory(root)!==before||(await readdir(root)).sort().join('\0')!==names.join('\0'))refuse();return records +} +const configuredTarget=()=>process.env.DATABASE_URL??'postgres://mmapp:mmapp@127.0.0.1:5432/mmapp' +export async function inspectDatabaseMaintenanceStartup(rawDirectory:string,runtimeTarget=configuredTarget()):Promise{ + const facts={journalOperations:0,coreApplied:0,outerAppliedReceipts:0,blockedReceipts:0,accessGenerationObserved:false,outerOperationInferred:false as const,auditImportStatus:'not-verified' as const,postRepairSchemaStatus:'not-inspected' as const,acceptedHistoricResolutions:0,historicOutcomesChanged:false as const,automaticRestore:false as const} + const result=(classification:MaintenanceStartupInspection['classification'],reason:Reason):MaintenanceStartupInspection=>Object.freeze({classification,reason,...facts}) + try{ + const state=await readMaintenanceRecoveryFacts(rawDirectory),records=await readMaintenanceAcceptanceRecords(rawDirectory) + facts.outerAppliedReceipts=state.vault.applied;facts.blockedReceipts=state.vault.blocked;facts.accessGenerationObserved=state.generation!==null + if(state.kind!=='journal'){if(records.length)refuse();return result(state.kind,'NONE')} + facts.journalOperations=state.operations.length;facts.coreApplied=state.operations.filter(op=>op.state==='applied').length + const accepted=evaluateMaintenanceAcceptances(records,state.journal,state.vault.evidence,records.length?runtimeDatabaseTargetSha256(runtimeTarget):'0'.repeat(64));facts.acceptedHistoricResolutions=accepted.count + const unresolved=state.operations.filter(op=>op.state!=='applied'&&!accepted.acceptedCoreIds.has(op.operationId)) + if(unresolved.some(op=>op.state==='intent'))return result('inspection-required','UNFINISHED_MAINTENANCE') + if(unresolved.some(op=>op.state==='unconfirmed'))return result('inspection-required','UNCONFIRMED_MAINTENANCE') + if(state.vault.unresolvedFiles.some(file=>!accepted.acceptedVaultFiles.has(file)))return result('inspection-required','EXTERNAL_OPERATION_UNRESOLVED') + return result(accepted.count?'accepted-current-state':'completed-core-maintenance','NONE') + }catch(error){return result('inspection-required',error instanceof Refusal?error.reason:'EXTERNAL_STATE_UNAVAILABLE_OR_MALFORMED')} +} +export class DatabaseMaintenanceStartupError extends Error { + readonly code='DATABASE_MAINTENANCE_INSPECTION_REQUIRED' + constructor(readonly inspection:MaintenanceStartupInspection){super('External database maintenance requires read-only inspection before managed startup')} +} +export async function assertDatabaseMaintenanceStartup(directory:string,runtimeTarget=configuredTarget()):Promise{ + const result=await inspectDatabaseMaintenanceStartup(directory,runtimeTarget) + if(result.classification==='inspection-required')throw new DatabaseMaintenanceStartupError(result) + return result +} +/** Audit recovery consumes the same strict grammar/claims as startup. No core + * applied record can manufacture an applied outer receipt or original actor. */ +export async function readAppliedRestoreAuditSources(directory:string,runtimeTarget=configuredTarget()):Promise{ + await assertDatabaseMaintenanceStartup(directory,runtimeTarget) + const before=await vaultFacts(directory) + await assertDatabaseMaintenanceStartup(directory,runtimeTarget) + const after=await vaultFacts(directory) + if(JSON.stringify(before)!==JSON.stringify(after))throw new DatabaseMaintenanceStartupError(await inspectDatabaseMaintenanceStartup(directory)) + return after.auditSources +} diff --git a/backend/src/services/database-request-gate.ts b/backend/src/services/database-request-gate.ts index 7ae2a912..750a4c4d 100644 --- a/backend/src/services/database-request-gate.ts +++ b/backend/src/services/database-request-gate.ts @@ -3,14 +3,47 @@ function conflict(message: string): Error & { statusCode: number } { return Object.assign(new Error(message), { statusCode: 409 }) } +type DatabaseAdmission = { active: boolean; kind: "request" | "exclusive"; requestAncestry: boolean; children: Set> } + /** Handler lifetime, not socket lifetime: disconnect does not finish an active write. */ export class DatabaseRequestGate { - private readonly requestContext = new AsyncLocalStorage() - assertNoRequestContext(): void { if (this.requestContext.getStore()) throw conflict("Scheduler bootstrap inside request context") } + private readonly requestContext = new AsyncLocalStorage() + assertNoRequestContext(): void { if (this.requestContext.getStore()?.requestAncestry) throw conflict("Scheduler bootstrap inside request context") } + /** Live private scope only; detached children cannot retain a settled admission. */ + hasActiveAdmission(): boolean { return this.requestContext.getStore()?.active === true } + /** Registration is synchronous; the parent drains this continuation and its + * descendants even if the caller deliberately does not await its result. */ + async withinAdmission(run: () => Promise): Promise { + const parent = this.requestContext.getStore() + if (!parent?.active) throw conflict("Database admission is no longer active") + const admission: DatabaseAdmission = { active: true, kind: parent.kind, requestAncestry: parent.requestAncestry, children: new Set() } + let release!: () => void + const settled = new Promise(resolve => { release = resolve }) + parent.children.add(settled) + try { return await this.requestContext.run(admission, run) } + finally { + await this.drainAdmission(admission) + parent.children.delete(settled) + release() + } + } + private async drainAdmission(admission: DatabaseAdmission): Promise { + admission.active = false + await Promise.all(admission.children) + } private openWaiters = new Set<() => void>() - isPaused(): boolean { return this.paused } + // Replacement uncertainty survives the temporary exclusive operation. + private admissionClosed = false + closeAdmission(): void { this.admissionClosed = true } + openAdmission(): void { this.admissionClosed = false; this.notifyOpen() } + private notifyOpen(): void { + if (this.isPaused()) return + const waiting = [...this.openWaiters]; this.openWaiters.clear() + for (const resolve of waiting) resolve() + } + isPaused(): boolean { return this.paused || this.admissionClosed } whenOpen(): Promise { - if (!this.paused) return Promise.resolve() + if (!this.isPaused()) return Promise.resolve() return new Promise(resolve => { this.openWaiters.add(resolve) }) } private paused = false @@ -19,10 +52,13 @@ export class DatabaseRequestGate { async request(run: () => Promise, cancelStream?: () => void): Promise { if (this.paused) throw conflict("Выполняется операция с базой приложения; повторите запрос позже") + if (this.admissionClosed) throw Object.assign(new Error("Схема базы после восстановления не подтверждена; требуется проверка или явное восстановление"), { statusCode: 503 }) const lease = Symbol() this.active.set(lease, cancelStream) - try { return await this.requestContext.run(true, run) } + const admission: DatabaseAdmission = { active: true, kind: "request", requestAncestry: true, children: new Set() } + try { return await this.requestContext.run(admission, run) } finally { + await this.drainAdmission(admission) this.active.delete(lease) if (!this.active.size) this.idle?.() } @@ -38,13 +74,13 @@ export class DatabaseRequestGate { this.idle = resolve timer = setTimeout(() => reject(conflict("Не удалось дождаться завершения API-запросов; операция с базой отменена")), timeoutMs) }) - return await run() + const admission: DatabaseAdmission = { active: true, kind: "exclusive", requestAncestry: this.requestContext.getStore()?.requestAncestry === true, children: new Set() } + try { return await this.requestContext.run(admission, run) } finally { await this.drainAdmission(admission) } } finally { if (timer) clearTimeout(timer) this.idle = undefined this.paused = false - const waiting = [...this.openWaiters]; this.openWaiters.clear() - for (const resolve of waiting) resolve() + this.notifyOpen() } } } diff --git a/backend/src/services/database-restore-audit.test.ts b/backend/src/services/database-restore-audit.test.ts new file mode 100644 index 00000000..dddb1379 --- /dev/null +++ b/backend/src/services/database-restore-audit.test.ts @@ -0,0 +1,110 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises' +import path from 'node:path' +import {fileURLToPath} from 'node:url' +import {randomUUID} from 'node:crypto' +import {EventEmitter} from 'node:events' +import type {Pool} from 'pg' +import {createRequire} from 'node:module' +import {runInNewContext} from 'node:vm' +import {reconcileRestoreReceiptAudit,inspectRestoreReceiptAudit,reconcileDatabaseRestoreAudits} from './database-restore-audit.js' +const here=path.dirname(fileURLToPath(import.meta.url)),backend=path.resolve(here,'../..'),temp=path.resolve(backend,'../tmp'),require=createRequire(import.meta.url),ts=require('typescript') +async function own(work:(root:string,operation:string,receipt:Record)=>Promise,old=false){ + await mkdir(temp,{recursive:true});const root=await mkdtemp(path.join(temp,'owned-restore-audit-')) + try{ + const operation=randomUUID(),archive=randomUUID(),core=randomUUID(),stamp=new Date(Date.now()-(old?181*86400000:1000)).toISOString() + const receipt={operationId:operation,archiveRef:archive,state:'applied',startedAt:stamp,automaticRetry:false,finishedAt:stamp,accessRevoked:true,warnings:[]} + await writeFile(path.join(root,'access-generation'),Buffer.alloc(32,11));await mkdir(path.join(root,'restore-vault')) + await writeFile(path.join(root,'database-maintenance.jsonl'),['intent','applied'].map(state=>JSON.stringify({operationId:core,state,at:stamp})).join('\n')+'\n') + await writeFile(path.join(root,'restore-vault',archive+'.used'),JSON.stringify({operationId:operation,at:stamp})) + await writeFile(path.join(root,'restore-vault',operation+'.receipt.json'),JSON.stringify(receipt)) + await work(root,operation,receipt) + }finally{assert(path.dirname(root)===temp&&path.basename(root).startsWith('owned-restore-audit-'));await rm(root,{recursive:true,force:true})} +} +type Row={actor_id:string;connection_id:null;action:string;payload:unknown;created_at?:string} +function model(){ + const rows:Row[]=[],queries:string[]=[],releases:boolean[]=[];let lostCommit=false,insertFailure=false,releaseFailure=false,offFailure=false,inserts=0 + const pool={connect:async()=>{ + let pending:Row[]=[] + const client=new EventEmitter(),originalOff=client.off.bind(client) + return Object.assign(client,{off:(name:string,listener:()=>void)=>{if(offFailure)throw Error('Synthetic listener cleanup failure');return originalOff(name,listener)},release:(bad:boolean)=>{releases.push(bad);if(releaseFailure)throw Error('Synthetic release failure')},query:async(config:{text:string;values?:unknown[]})=>{ + const text=config.text,values=config.values??[];queries.push(text) + if(text.startsWith('SELECT audit_retention'))return {rows:[{days:180,now:String(Date.now())}],command:'SELECT'} + if(text.startsWith('SELECT actor_id'))return {rows:rows.filter(row=>(row.payload as {operationId:string}).operationId===values[1]).slice(0,2),command:'SELECT'} + if(text.startsWith('INSERT')){if(insertFailure)throw Error('Synthetic audit unavailable');inserts++;pending.push({actor_id:String(values[0]),action:String(values[1]),connection_id:null,payload:JSON.parse(String(values[2])),created_at:String(values[3])});return {rows:[],command:'INSERT'}} + if(text==='COMMIT'){rows.push(...pending);pending=[];if(lostCommit){lostCommit=false;throw Error('Synthetic COMMIT delivery loss')}return {rows:[],command:'COMMIT'}} + if(text==='ROLLBACK')pending=[] + return {rows:[],command:text.split(' ')[0]} + }}) + }} as unknown as Pick + return {pool,rows,queries,releases,inserts:()=>inserts,loseCommit:()=>{lostCommit=true},failInsert:()=>{insertFailure=true},failRelease:()=>{releaseFailure=true},failOff:()=>{offFailure=true}} +} +test('live and startup audit writers converge through read-first without changing external receipt',()=>own(async(root,id,receipt)=>{ + const m=model(),file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file) + const live=await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner');assert.equal(live.status,'imported');assert.equal(live.originalActorKnown,true) + assert.equal(m.rows[0].action,'database.restore.receipt');assert.deepEqual(m.rows[0].payload,receipt);assert.equal(m.rows[0].actor_id,'actual-owner') + await reconcileDatabaseRestoreAudits(m.pool,root);assert.equal(m.inserts(),1) + const status=await inspectRestoreReceiptAudit(m.pool,root,id);assert.equal(status.status,'already-recorded');assert.equal(m.inserts(),1) + assert(m.queries.some(q=>q==='BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY'));assert(m.queries.every(q=>!/^CREATE|^ALTER|^DROP|pg_restore|psql/.test(q))) + assert.deepEqual(await readFile(file),before) +})) +test('legacy missing audit imports exact receipt evidence under system importer without inventing human',()=>own(async(root,id)=>{ + const m=model();await reconcileDatabaseRestoreAudits(m.pool,root) + assert.equal(m.rows.length,1);assert.equal(m.rows[0].actor_id,'database-maintenance-reconciler');assert.equal(m.rows[0].action,'database.restore.receipt.recovered') + assert.equal((m.rows[0].payload as {originalActorKnown:boolean}).originalActorKnown,false) + assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner')).status,'already-recorded');assert.equal(m.inserts(),1) +})) +test('lost audit COMMIT destroys owner and reconciles observed existing row without another insert',()=>own(async(root,id)=>{ + const m=model();m.loseCommit() + assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'unconfirmed');assert.equal(m.releases[0],true) + assert.equal(m.queries.filter(value=>value==='ROLLBACK').length,0,'Uncertain COMMIT owner must be destroyed, not rolled back or reused') + assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'already-recorded');assert.equal(m.inserts(),1) +})) +test('conflicting or duplicate audit evidence is never treated as imported',()=>own(async(root,id)=>{ + const m=model();m.rows.push({actor_id:'foreign',connection_id:null,action:'database.restore.receipt',payload:{operationId:id,foreign:true}}) + assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'conflict');assert.equal(m.inserts(),0) + await assert.rejects(reconcileDatabaseRestoreAudits(m.pool,root),error=>error instanceof Error&&'code'in error&&error.code==='DATABASE_RESTORE_AUDIT_PENDING') +})) +test('audit retention is not reset by receipt reimport',()=>own(async(root,id)=>{ + const m=model();assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'outside-retention');assert.equal(m.inserts(),0) +},true)) +test('audit outage remains pending and never overwrites fsynced applied receipt',()=>own(async(root,id)=>{ + const m=model();m.failInsert();const file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file) + assert.equal((await reconcileRestoreReceiptAudit(m.pool,root,id)).status,'pending');assert.deepEqual(await readFile(file),before) +})) +for(const fault of ['release','listener'])test('confirmed audit COMMIT survives '+fault+' cleanup failure without relabeling restore',()=>own(async(root,id)=>{ + const m=model();if(fault==='release')m.failRelease();else m.failOff() + const file=path.join(root,'restore-vault',id+'.receipt.json'),before=await readFile(file) + const result=await reconcileRestoreReceiptAudit(m.pool,root,id,'actual-owner') + assert.equal(result.status,'imported');assert.equal(result.cleanupUnconfirmed,true);assert.equal(m.rows.length,1) + assert.equal(m.releases.length,1,'Listener cleanup failure must not bypass actual owner release') + assert.deepEqual(await readFile(file),before) +})) +for(const legacyImport of [false,true])test('actual bootstrap audit follows migrations and legacy import='+legacyImport+' before business startup',()=>own(async(root)=>{ + const source=await readFile(path.join(backend,'src/db/bootstrap.ts'),'utf8'),events:string[]=[],exports:Record={} + const query=async()=>{events.push('sql');return {rows:[{sqlite_imported_at:'done'}]}},client={query,release:()=>{}} + const modules:Record={ + './index.js':{pool:{query,connect:async()=>client}},'./migrate.js':{applySqlMigrations:async()=>events.push('migrations')}, + './partitions.js':{dropExpiredPartitions:async()=>events.push('drop'),ensurePartitionsAround:async()=>events.push('partitions')}, + './sqlite-import.js':{shouldImportSqlite:async()=>legacyImport,sqliteFileLooksPresent:()=>false,importSqliteToPostgres:async()=>{events.push('import');return {durationMs:0,tables:{}}}}, + '../config.js':{env:{DATABASE_PATH:'synthetic'}},'../modules/mcp/security.js':{mcpStateDirectory:()=>root}, + '../services/database-maintenance-startup.js':{assertDatabaseMaintenanceStartup:async()=>events.push('guard')}, + '../services/database-restore-audit.js':{reconcileDatabaseRestoreAudits:async()=>{events.push('audit');throw Error('Pending audit import')}}, + } + runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require:(name:string)=>{assert(name in modules);return modules[name]},console}) + await assert.rejects((exports.initDatabase as ()=>Promise)(),/Pending audit import/) + assert.equal(events[0],'guard');assert.equal(events.at(-1),'audit');assert(events.indexOf('migrations'){ + const source=await readFile(path.join(backend,'src/modules/mcp/database-restore.ts'),'utf8'),tree=ts.createSourceFile('restore.ts',source,ts.ScriptTarget.ES2022,true) + let callAncestors:unknown[]=[] + function visit(node:{kind:number;expression?:{getText:()=>string};getChildren:()=>unknown[]},ancestors:unknown[]=[]){ + if(node.kind===ts.SyntaxKind.CallExpression&&node.expression?.getText()==='reconcileRestoreReceiptAudit')callAncestors=ancestors + for(const child of node.getChildren())visit(child as typeof node,[...ancestors,node]) + } + visit(tree) + const tries=callAncestors.filter((node:unknown)=>(node as {kind:number}).kind===ts.SyntaxKind.TryStatement) as {getText:()=>string}[] + assert.equal(tries.length,1);assert(!tries[0].getText().includes('state: attempted ? "unknown"')) + assert(source.includes('restoreActorId=connection.owner_id')) +}) diff --git a/backend/src/services/database-restore-audit.ts b/backend/src/services/database-restore-audit.ts new file mode 100644 index 00000000..9ab173bb --- /dev/null +++ b/backend/src/services/database-restore-audit.ts @@ -0,0 +1,80 @@ +/** Specific external receipt -> audit import. Never dispatches restore or + * changes journal/generation/receipt. Both live and startup writers use it. */ +import type {Pool,PoolClient,QueryConfig,QueryResultRow} from 'pg' +import {isDeepStrictEqual} from 'node:util' +import {env} from '../config.js' +import {readAppliedRestoreAuditSources,type AppliedRestoreAuditSource} from './database-maintenance-startup.js' + +const LIVE='database.restore.receipt',RECOVERED='database.restore.receipt.recovered',IMPORTER='database-maintenance-reconciler' +export type RestoreAuditStatus='imported'|'already-recorded'|'missing'|'outside-retention'|'pending'|'unconfirmed'|'conflict' +export interface RestoreAuditInspection {readonly operationId:string;readonly receiptSha256:string;readonly status:RestoreAuditStatus; + /** Recorded audit actor or live validated owner; not cryptographic provenance. */ + readonly originalActorKnown:boolean;readonly cleanupUnconfirmed:boolean;readonly automaticRestore:false} +const boundedQuery=(text:string,values:unknown[]=[]):QueryConfig&{query_timeout:number}=>({text,values,query_timeout:10000}) + +type AuditRow=QueryResultRow&{actor_id:string;connection_id:string|null;action:string;payload:unknown} +function recoveryPayload(source:AppliedRestoreAuditSource){return {operationId:source.operationId,externalReceiptSha256:source.receiptSha256,originalActorKnown:false,receipt:source.receipt}} +function status(source:AppliedRestoreAuditSource,value:RestoreAuditStatus,known=false):RestoreAuditInspection{return Object.freeze({operationId:source.operationId,receiptSha256:source.receiptSha256,status:value,originalActorKnown:known,cleanupUnconfirmed:false,automaticRestore:false})} + +async function one(pool:Pick,source:AppliedRestoreAuditSource,write:boolean,actorId?:string):Promise{ + let client:PoolClient|undefined,begun=false,commitSent=false,committed=false,bad=false,faulted=false,cleanupUnconfirmed=false,result:RestoreAuditInspection + const fault=()=>{faulted=true} + const check=()=>{if(faulted)throw Error('Audit owner unavailable')} + try{ + result=await(async()=>{ + client=await pool.connect();client.on('error',fault);client.on('end',fault) + const query=async(text:string,values:unknown[]=[])=>{check();const result=await client!.query(boundedQuery(text,values));check();return result} + await query(write?'BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE':'BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY');begun=true + await query("SET LOCAL statement_timeout='10s'");await query("SET LOCAL lock_timeout='5s'") + await query('SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended($1,0))',['mcp-restore-audit:'+source.operationId]) + const settings=await query<{days:number;now:string}>('SELECT audit_retention_days AS days,(EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now FROM public.mcp_settings WHERE id=1 LIMIT 2') + const config=settings.rows[0],finished=Date.parse(source.finishedAt) + if(settings.rows.length!==1||!config||!Number.isInteger(config.days)||config.days<30||config.days>3650||!/^\d{10,16}$/.test(config.now)||!Number.isSafeInteger(Number(config.now))||!Number.isFinite(finished)||finished>Number(config.now)+5000)throw Error('Audit retention/clock unavailable') + if(finished("SELECT actor_id,connection_id,action,payload FROM public.mcp_audit WHERE action=ANY($1::text[]) AND payload->>'operationId'=$2 ORDER BY id LIMIT 2",[[LIVE,RECOVERED],source.operationId]) + if(existing.rows.length>1)throw Object.assign(Error('Audit receipt conflict'),{auditConflict:true}) + if(existing.rows.length){ + const row=existing.rows[0] + const live=row.action===LIVE&&typeof row.actor_id==='string'&&row.actor_id.length>0&&row.connection_id===null&&isDeepStrictEqual(row.payload,source.receipt)&&(!actorId||row.actor_id===actorId) + const recovered=row.action===RECOVERED&&row.actor_id===IMPORTER&&row.connection_id===null&&isDeepStrictEqual(row.payload,recoveryPayload(source)) + if(!live&&!recovered)throw Object.assign(Error('Audit receipt conflict'),{auditConflict:true}) + const end=await query('ROLLBACK');if(end.command!=='ROLLBACK')throw Error('Audit rollback unconfirmed');begun=false + return status(source,'already-recorded',live) + } + if(!write){const end=await query('ROLLBACK');if(end.command!=='ROLLBACK')throw Error('Audit rollback unconfirmed');begun=false;return status(source,'missing')} + if(actorId!==undefined&&(typeof actorId!=='string'||!actorId))throw Error('Audit actor unavailable') + const actor=actorId??IMPORTER,action=actorId?LIVE:RECOVERED,payload=actorId?source.receipt:recoveryPayload(source) + await query('INSERT INTO public.mcp_audit(actor_id,action,connection_id,payload,created_at) VALUES($1,$2,NULL,$3::jsonb,$4::timestamptz)',[actor,action,JSON.stringify(payload),source.finishedAt]) + commitSent=true;const end=await client!.query(boundedQuery('COMMIT'));if(end.command!=='COMMIT')throw Error('Audit COMMIT acknowledgement unavailable');committed=true;begun=false + if(faulted){bad=true;cleanupUnconfirmed=true} + return status(source,'imported',!!actorId) + })() + }catch(error){ + bad=true + result=status(source,commitSent&&!committed?'unconfirmed':error&&typeof error==='object'&&'auditConflict'in error?'conflict':'pending') + }finally{ + if(client){ + if(begun&&!commitSent&&!faulted)try{const end=await client.query(boundedQuery('ROLLBACK'));if(end.command!=='ROLLBACK'){bad=true;cleanupUnconfirmed=true}}catch{bad=true;cleanupUnconfirmed=true} + try{client.off('error',fault)}catch{bad=true;cleanupUnconfirmed=true} + try{client.off('end',fault)}catch{bad=true;cleanupUnconfirmed=true} + try{client.release(bad||faulted||commitSent&&!committed)}catch{cleanupUnconfirmed=true} + } + } + return cleanupUnconfirmed?Object.freeze({...result,cleanupUnconfirmed:true}):result +} +export async function reconcileRestoreReceiptAudit(pool:Pick,directory:string,operationId:string,actorId?:string):Promise{ + const sources=await readAppliedRestoreAuditSources(directory,env.DATABASE_URL),source=sources.find(value=>value.operationId===operationId) + if(!source)throw Error('Confirmed external receipt unavailable') + return one(pool,source,true,actorId) +} +export async function inspectRestoreReceiptAudit(pool:Pick,directory:string,operationId:string):Promise{ + const sources=await readAppliedRestoreAuditSources(directory,env.DATABASE_URL),source=sources.find(value=>value.operationId===operationId) + if(!source)throw Error('Confirmed external receipt unavailable') + return one(pool,source,false) +} +export async function reconcileDatabaseRestoreAudits(pool:Pick,directory:string):Promise{ + for(const source of await readAppliedRestoreAuditSources(directory,env.DATABASE_URL)){ + const result=await one(pool,source,true) + if(result.cleanupUnconfirmed||!['imported','already-recorded','outside-retention'].includes(result.status))throw Object.assign(Error('External restore audit requires reconciliation before managed startup'),{code:'DATABASE_RESTORE_AUDIT_PENDING',auditStatus:result.cleanupUnconfirmed?'cleanup-unconfirmed':result.status}) + } +} diff --git a/backend/src/services/detached-database-admission.test.ts b/backend/src/services/detached-database-admission.test.ts new file mode 100644 index 00000000..f361c3cf --- /dev/null +++ b/backend/src/services/detached-database-admission.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { readFile } from "node:fs/promises" +import { runInNewContext } from "node:vm" +import { AsyncLocalStorage } from "node:async_hooks" +import { EventEmitter } from "node:events" +import * as crypto from "node:crypto" +import { performance } from "node:perf_hooks" +import path from "node:path" +import os from "node:os" +import { z } from "zod" +import ts from "typescript" +import type { PoolClient } from "pg" +import type { McpPrincipal } from "../modules/mcp/store.js" +import { DatabaseRequestGate } from "./database-request-gate.js" +import * as operation from "./system-database-operation.js" +import * as maintenance from "../modules/mcp/database-maintenance.js" + +async function load(file: URL, modules: Record): Promise { + const exports = {} + const source = ts.transpileModule(await readFile(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText + runInNewContext(source, { exports, require: (name: string) => { assert(name in modules, `Unapproved import ${name}`); return modules[name] }, Buffer, Date, Error, Promise, structuredClone, AbortController, setTimeout, clearTimeout, queueMicrotask, console, process: { env: {} } }) + return exports as T +} +function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done }); return { promise, resolve } } +async function bounded(work: Promise): Promise { + let timer: ReturnType | undefined + try { return await Promise.race([work, new Promise((_, reject) => { timer = setTimeout(() => reject(Error("Owned pure check deadline")), 1500) })]) } + finally { clearTimeout(timer) } +} +const principal: McpPrincipal = { connectionId: "00000000-0000-4000-8000-000000000001", credentialId: "00000000-0000-4000-8000-000000000002", credentialExpiresAt: new Date(Date.now() + 600000).toISOString() } +class McpError extends Error { constructor(readonly code: string, message: string, readonly statusCode = 403) { super(message) } } + +async function originFixture(gate: DatabaseRequestGate) { + let checkouts = 0, sqlCalls = 0, commits = 0 + let hold: (() => Promise) | undefined + const committed = deferred(), events: string[] = [], generation = Buffer.alloc(32, 71) + const cleanupQuery = async (raw: string | { text: string }) => { + assert(gate.hasActiveAdmission(), "Cleanup SQL must own live admission") + const text = typeof raw === "string" ? raw : raw.text; sqlCalls++; events.push(text) + if (text === "COMMIT") { commits++; if (commits === 2) committed.resolve() } + return { command: text === "BEGIN" || text === "COMMIT" ? text : "UPDATE", rows: text.startsWith("UPDATE public.mcp_operations") ? [{ plan_id: "owned-plan" }] : [] } + } + const pool = { connect: async () => { assert(gate.hasActiveAdmission()); checkouts++; await hold?.(); return { query: cleanupQuery, release() {} } } } + const connection = { generation: generation.toString("hex"), policy: { version: 1, expiresAt: new Date(Date.now() + 600000).toISOString() }, owner_valid_until: new Date(Date.now() + 600000) } + const origin = await load(new URL("../modules/mcp/operation-execution-origin.ts", import.meta.url), { + "node:async_hooks": { AsyncLocalStorage }, "node:crypto": crypto, "node:path": path, "node:perf_hooks": { performance }, + "node:fs": { readFileSync: (file: string) => Buffer.alloc(32, file.endsWith("access-generation") ? 71 : 72) }, + "node:fs/promises": { readFile: async () => Buffer.from(generation) }, + "@mmapp/contracts/mcp": { mcpTemporaryGrantSchema: { parse: (value: unknown) => value } }, + "../../db/index.js": { pool }, "../../services/shared-database-gate.js": { sharedDatabaseGate: gate }, + "../../services/config-mutation-lock.js": { assertNoConfigurationLockContext() {} }, "../../services/operation-config-lock-lease.js": { assertNoOperationConfigurationLockContext() {} }, + "./store.js": { validatePrincipal: async () => connection, audit: async (db: { query: typeof cleanupQuery }) => db.query("INSERT INTO inert_audit") }, + "./security.js": { McpError, mcpStateDirectory: () => "/inert-owned-state", fingerprint: () => "inert", decryptSecret: async (value: string) => value, privateFingerprinterExisting: async () => () => "inert-key" }, + "./actions.js": { findActionByOperation: () => ({ operation: { version: 1 } }) }, "./policy.js": { evaluateMcpPolicy: () => ({ decision: "allowed" }) }, "./change-cost.js": { changeMutationCost: () => 1 }, + "./temporary-grant-origin.js": { assertOriginalMcpTemporaryGrant: async () => {} }, + }) + const mint = async () => { + let id = "", planId = "" + const result = (command: string, rows: unknown[]) => ({ command, rows, rowCount: rows.length }) + const client = { query: async (sql: string, args: string[] = []) => { + if (sql.startsWith("SELECT pg_current_xact_id")) return result("SELECT", [{ creating_xid: "123" }]) + if (sql.startsWith("INSERT INTO mcp_operations")) { [id, planId] = args; return result("INSERT", [{ id, plan_id: planId, connection_id: principal.connectionId, state: "pending" }]) } + assert(sql.includes("AS creating_transaction")); return result("SELECT", [{ id, plan_id: planId, connection_id: principal.connectionId, creating_transaction: true, operation_id: "users.create", operation_version: 1, policy_version: 1, generation: connection.generation, expires_at: new Date(Date.now() + 600000), evidence: { sealed: "owned-evidence" }, payload: { sealed: "owned-payload" } }]) + } } + const start = origin.beginMcpOperationAdmission(principal) + return (await origin.insertMcpOperationAdmission(client as unknown as PoolClient, start, crypto.randomUUID(), crypto.randomUUID(), { proof: "inert", expiresAt: null, monotonicEnd: Infinity, admissionExpiresAt: Date.now() + 600000, admissionMonotonicEnd: performance.now() + 600000 })).admission + } + return { origin, mint, events, committed, counts: () => ({ checkouts, sqlCalls, commits }), hold: (work?: () => Promise) => { hold = work } } +} +async function actualBoundedRunner() { + const runtime = await readFile(new URL("../modules/mcp/runtime.ts", import.meta.url), "utf8") + const begin = runtime.indexOf("let activeRunners = 0"), end = runtime.indexOf("async function policyInput", begin) + assert(begin > 0 && end > begin) + // Execute the existing private slot owner directly, not a mirrored semaphore. + const code = ts.transpileModule(runtime.slice(begin, end) + "\nexports.runBounded = runBounded", { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const exports = {} as { runBounded(work: () => Promise): Promise } + runInNewContext(code, { exports, Promise }); return exports.runBounded +} + +test("deferred one-shot cleanup owns no bounded runner slot and emits no SQL until repair admission", async () => { + const gate = new DatabaseRequestGate(), f = await originFixture(gate), runBounded = await actualBoundedRunner(), first = await f.mint(), second = await f.mint() + gate.closeAdmission() + await bounded(Promise.all([runBounded(() => f.origin.retireMcpOperationAdmission(first)), runBounded(() => f.origin.retireMcpOperationAdmission(second))])) + assert.deepEqual(f.counts(), { checkouts: 0, sqlCalls: 0, commits: 0 }) + await bounded(runBounded(() => gate.exclusive(async () => { assert.equal(f.counts().sqlCalls, 0); gate.openAdmission() }))) + await bounded(f.committed.promise); assert.equal(f.counts().commits, 2) + const before = f.counts(); await f.origin.retireMcpOperationAdmission(first); await f.origin.retireMcpOperationAdmission(second); assert.deepEqual(f.counts(), before) +}) +test("cleanup already inside admitted work completes while exclusive drain waits, without inversion", async () => { + const gate = new DatabaseRequestGate(), f = await originFixture(gate), cap = await f.mint(), entered = deferred(), release = deferred() + f.hold(async () => { entered.resolve(); await release.promise }) + const request = gate.request(() => f.origin.retireMcpOperationAdmission(cap)); await entered.promise + let replaced = false; const exclusive = gate.exclusive(async () => { assert.equal(f.counts().commits, 1); replaced = true }) + assert.equal(replaced, false); release.resolve(); await bounded(Promise.all([request, exclusive])); assert.equal(replaced, true) +}) +test("temporary exclusive also defers outside cleanup until its callback has completed", async () => { + const gate = new DatabaseRequestGate(), f = await originFixture(gate), cap = await f.mint(), entered = deferred(), release = deferred() + const exclusive = gate.exclusive(async () => { entered.resolve(); await release.promise }); await entered.promise + await bounded(f.origin.retireMcpOperationAdmission(cap)); assert.equal(f.counts().checkouts, 0) + release.resolve(); await exclusive + await bounded(gate.request(async () => { while (f.counts().commits !== 1) await new Promise(resolve => setTimeout(resolve, 0)) })) +}) +test("settled request ancestry cannot grant cleanup admission or be laundered into scheduler bootstrap", async () => { + const gate = new DatabaseRequestGate(), entered = deferred(), release = deferred() + let stale: Promise | undefined + await gate.request(async () => { stale = (async () => { entered.resolve(); await release.promise; assert.equal(gate.hasActiveAdmission(), false); assert.throws(() => gate.assertNoRequestContext(), /request context/); await gate.exclusive(async () => { assert(gate.hasActiveAdmission()); assert.throws(() => gate.assertNoRequestContext(), /request context/) }) })() }) + await entered.promise; release.resolve(); await stale + await gate.exclusive(async () => { assert(gate.hasActiveAdmission()); assert.doesNotThrow(() => gate.assertNoRequestContext()) }) +}) + +async function serviceFixture(gate: DatabaseRequestGate) { + const calls: string[] = [], readiness = operation.createDatabaseSchemaReadiness() + const spawn = (command: string) => { + calls.push(command); const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; stdin: { end(): void } } + child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = { end: () => queueMicrotask(() => child.emit("close", 0)) }; return child + } + const service = await load(new URL("./system-database-backup.ts", import.meta.url), { + "node:crypto": crypto, "node:async_hooks": { AsyncLocalStorage }, "node:fs/promises": { open: async () => ({ writeFile: async () => {}, sync: async () => {}, close: async () => {} }), chmod: async () => {}, mkdir: async () => {}, rm: async () => {}, writeFile: async () => {} }, "node:os": os, "node:path": path, "node:child_process": { spawn }, + "../modules/events/service/events-service.js": { appendEvent: async () => {} }, "./database-dump-connection.js": { runPrivateDatabaseDump: async () => Buffer.alloc(0) }, "./database-control-archive-boundary.js": { DATABASE_CONTROL_DUMP_EXCLUDE_SCHEMA_ARG: "--exclude-schema=mmapp_control" }, + "./shared-database-gate.js": { sharedDatabaseGate: gate, databaseSchemaReadiness: readiness }, "./config-mutation-policy.js": { assertDatabaseRestoreAllowed: async () => {} }, "../modules/mcp/security.js": { accessGeneration: async () => "inert", invalidateMcpAccess: async () => {}, mcpStateDirectory: () => "/inert-owned-state" }, "../modules/mcp/database-maintenance.js": maintenance, + "../db/migrate.js": { applySqlMigrations: async () => { assert(gate.hasActiveAdmission()); calls.push("migrations") } }, "./system-database-restore-execution.js": { validateSystemDatabaseRestoreArchive() {}, executeSystemDatabaseRestore: async (_buffer: Buffer, _url: string, deps: { run(args: string[]): Promise }) => { await deps.run(["owned.dump"]); return {} } }, + "../config.js": { env: { DATABASE_URL: "inert-owned-database" } }, "../db/index.js": { pool: { connect: () => assert.fail("Native SQL forbidden") } }, "../db/operation-lock.js": { withOperationLock: async (_connect: unknown, _key: number, work: () => Promise) => { assert(gate.hasActiveAdmission()); calls.push("operation-lock"); return work() } }, "./system-database-operation.js": operation, + "./scheduler.js": { stopSchedulerForDatabaseOperation: async () => {}, restartSchedulerAfterDatabaseOperation: async () => { calls.push("scheduler-restart") } }, "./traffic-flow-ripe.js": { pauseRipeBackgroundWork: async () => {}, resumeRipeBackgroundWork() {} }, "./system-database-cache.js": { invalidateSystemDatabaseCaches() {} }, + "./system-database-partition-restore.js": { restorePartitionedDatabase: async (_file: string, _url: string, deps: { beforeTargetReplacement(): void }) => { assert(gate.hasActiveAdmission()); assert(gate.isPaused()); deps.beforeTargetReplacement(); calls.push("replacement") } }, "./traffic-flow-ingest.js": { stopTrafficFlowListenerForDatabaseOperation: async () => {}, restartTrafficFlowListenerAfterDatabaseOperation: async () => {} }, + }) + return { service, calls } +} +test("terminal helper grants matching inner restore exactly one exclusive admission and revokes stale handoff", async () => { + const gate = new DatabaseRequestGate(), f = await serviceFixture(gate), exclusive = gate.exclusive.bind(gate); let entries = 0 + Object.defineProperty(gate, "exclusive", { value: (work: () => Promise) => { entries++; return exclusive(work) } }) + let late: Promise | undefined; const release = deferred() + await f.service.withSystemDatabaseRestoreAdmission(async () => { await f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP")); late = release.promise.then(() => f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP"))) }) + assert.equal(entries, 1); assert(f.calls.includes("replacement")); release.resolve(); await late; assert.equal(entries, 2) + await f.service.withSystemDatabaseBackupAdmission(async () => { await assert.rejects(f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP")), /уже выполняется/) }) +}) +test("actual terminal first pool checkout and claim are inside exclusive, never during another restore", async () => { + const gate = new DatabaseRequestGate(), f = await serviceFixture(gate); let checkouts = 0, claims = 0 + const terminal = await load(new URL("../modules/mcp/database-restore.ts", import.meta.url), { + "node:crypto": crypto, "node:fs/promises": {}, "node:path": path, zod: { z }, "../../db/index.js": { pool: { connect: async () => { assert(gate.hasActiveAdmission()); assert(gate.isPaused()); checkouts++; return { query: async () => { assert(gate.hasActiveAdmission()); claims++; return { rows: [{ locked: false }] } }, release: () => assert(gate.hasActiveAdmission()) } } } }, + "../../lib/permissions.js": {}, "../../services/system-database-restore-execution.js": {}, "../../services/system-database-backup.js": f.service, "../../services/config-mutation-policy.js": {}, "./security.js": {}, "./store.js": {}, "./state-file-publish.js": {}, "../../services/config-mutation-lock.js": {}, "./temporary-grant-origin.js": {}, "../../services/database-restore-audit.js": {}, + }) + const entered = deferred(), release = deferred(), other = gate.exclusive(async () => { entered.resolve(); await release.promise }); await entered.promise + await assert.rejects(terminal.executeMcpDatabaseRestore(crypto.randomUUID(), principal), /уже выполняется/); assert.equal(checkouts, 0) + release.resolve(); await other + await terminal.executeMcpDatabaseRestore(crypto.randomUUID(), principal); assert.equal(checkouts, 1); assert.equal(claims, 1) +}) + + +test("detached cleanup begun in a live parent pins admission after that parent callback returns", async () => { + const gate = new DatabaseRequestGate(), f = await originFixture(gate), cap = await f.mint() + const checkout = deferred(), releaseCheckout = deferred(), parentReturned = deferred() + f.hold(async () => { checkout.resolve(); await releaseCheckout.promise }) + let cleanup!: Promise + const request = gate.request(async () => { + cleanup = f.origin.retireMcpOperationAdmission(cap) + await checkout.promise + parentReturned.resolve() + // Deliberately do not await the detached cleanup. + }) + await parentReturned.promise + let replaced = false + const exclusive = gate.exclusive(async () => { assert.equal(f.counts().commits, 1); replaced = true }) + await new Promise(resolve => setTimeout(resolve, 10)) + assert.equal(replaced, false); assert.equal(f.counts().sqlCalls, 0) + releaseCheckout.resolve() + await bounded(Promise.all([request, cleanup, exclusive])) + assert.equal(replaced, true) +}) + +test("detached matching restore handoff pins exclusive through an already-started inner restore", async () => { + const gate = new DatabaseRequestGate(), f = await serviceFixture(gate), entered = deferred(), release = deferred() + const scoped = gate.withinAdmission.bind(gate) + Object.defineProperty(gate, "withinAdmission", { value: (work: () => Promise) => scoped(async () => { + entered.resolve(); await release.promise; return work() + }) }) + let inner!: Promise + const outer = f.service.withSystemDatabaseRestoreAdmission(async () => { + inner = f.service.restoreSystemDatabaseBackup(Buffer.from("PGDMP")) + await entered.promise + }) + await entered.promise + await new Promise(resolve => setTimeout(resolve, 10)) + assert(gate.isPaused()) + await assert.rejects(gate.exclusive(async () => {}), /уже выполняется/) + assert(!f.calls.includes("operation-lock")); assert(!f.calls.includes("replacement")) + release.resolve(); await bounded(Promise.all([outer, inner])) + assert(f.calls.includes("replacement")); assert.equal(gate.isPaused(), false) +}) + + +test("retained child rejection preserves drain and never grants a stale child admission", async () => { + const gate = new DatabaseRequestGate(), started = deferred(), release = deferred() + let child!: Promise, late!: () => Promise + const request = gate.request(async () => { + child = gate.withinAdmission(async () => { + const inherited = AsyncLocalStorage.snapshot() + late = () => inherited(() => gate.withinAdmission(async () => assert.fail("Stale work cannot run"))) + started.resolve(); await release.promise; throw Error("Expected child rejection") + }) + void child.catch(() => {}) + await started.promise + }) + await started.promise + let replaced = false; const exclusive = gate.exclusive(async () => { replaced = true }) + assert.equal(replaced, false); release.resolve() + await assert.rejects(child, /Expected child rejection/) + await bounded(Promise.all([request, exclusive])) + assert.equal(replaced, true) + // Invoke in the inherited child context, not this outside test context. + await assert.rejects(late(), /no longer active/) +}) diff --git a/backend/src/services/routing-table-fib.test.ts b/backend/src/services/routing-table-fib.test.ts new file mode 100644 index 00000000..536a02c5 --- /dev/null +++ b/backend/src/services/routing-table-fib.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { isConfirmedRoutingFibTable } from "./routing-table-fib.js" + +test("native FIB switch and explicit enabled representations preserve the table", () => { + for (const fib of ["", true, "true", "yes"]) assert.equal(isConfirmedRoutingFibTable({ name: "main", fib, invalid: "false" }), true) +}) +test("absent or false FIB never confirms a forwarding table", () => { + for (const fib of [undefined, null, false, "false", "no", "unknown", "0", 1]) assert.equal(isConfirmedRoutingFibTable({ fib }), false) +}) +test("disabled, invalid and ambiguous status cannot be promoted by the FIB switch", () => { + for (const key of ["disabled", "invalid"]) for (const value of [true, "true", "yes", "", null, "unknown"]) assert.equal(isConfirmedRoutingFibTable({ fib: "", [key]: value }), false) +}) diff --git a/backend/src/services/routing-table-fib.ts b/backend/src/services/routing-table-fib.ts new file mode 100644 index 00000000..be95e527 --- /dev/null +++ b/backend/src/services/routing-table-fib.ts @@ -0,0 +1,8 @@ +/** RouterOS fib is a CLI switch: its enabled REST representation may be "". + * Do not interpret empty strings as true for general boolean properties. + * https://manual.mikrotik.com/docs/cli-reference/routing/table/ */ +export function isConfirmedRoutingFibTable(row: Readonly>): boolean { + const fib = row.fib + if (![true, "true", "yes", ""].includes(fib as string | boolean)) return false + return ["disabled", "invalid"].every(key => row[key] === undefined || [false, "false", "no"].includes(row[key] as string | boolean)) +} diff --git a/backend/src/services/routing-topology-observation.test.ts b/backend/src/services/routing-topology-observation.test.ts new file mode 100644 index 00000000..5ff30e72 --- /dev/null +++ b/backend/src/services/routing-topology-observation.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { projectRoutingImmediateGateways, readRoutingTopology } from "./routing-topology-observation.js" +const route = { ".id": "*r", afi: "ip", "dst-address": "192.0.2.0/24", "routing-table": "main", active: "true", contribution: "active", gateway: "198.51.100.1", "immediate-gw": "198.51.100.1%wan", comment: "NEVER_PRIVATE", "bgp.peer-cache-id": "*opaque", "bgp.session": "*opaque" } +const ports = [{ name: "wan", running: "true" }], tables = [{ name: "main", fib: "true" }] +const project = (row = route, version = "7.24.5 (stable)", interfaces: unknown = ports, catalogue: unknown = tables) => projectRoutingImmediateGateways(version, [row], interfaces, catalogue) +test("documented IPv4 final gateway is reachable without operation/BGP namespace assertions", () => { + const result = project(); assert.equal(result.status, "observed"); assert.equal(result.items[0].status, "observed") + const item = result.items[0]; if (item.status !== "observed") throw Error("Missing observation") + assert.deepEqual(item.immediateGateway, { address: "198.51.100.1", interface: "wan" }) + assert.equal(item.routeOriginVerified, false); assert.equal(item.recursiveChainVerified, false); assert.equal(item.semanticProof, false) + assert(!JSON.stringify(result).includes("NEVER_PRIVATE")); assert(!JSON.stringify(result).includes("opaque")) +}) +test("IPv6 endpoint and directly connected interface are observed separately from full recursion", () => { + const ipv6 = project({ ...route, afi: "ipv6", "dst-address": "2001:db8::/64", "immediate-gw": "2001:db8::1%wan" }) + assert.equal(ipv6.items[0].status, "observed") + const direct = project({ ...route, connect: "true", "immediate-gw": "wan" } as typeof route) + assert.equal(direct.items[0].status, "observed") +}) +for (const [name, changed] of Object.entries({ multipath: { ecmp: "true" }, absentActive: { active: undefined }, disabled: { disabled: "true" }, unavailable: { "immediate-gw": "" }, numericOnly: { "immediate-gw": "17" }, wrongFamily: { "immediate-gw": "2001:db8::1%wan" }, wrongVrf: { "immediate-gw": "198.51.100.1%wan@other" } })) test(name + " stays unknown", () => { + assert.equal(project({ ...route, ...changed } as typeof route).items[0].status, "unknown") +}) +test("unsupported version, missing/duplicate/down interface and missing/nonfib table stay unknown", () => { + assert.equal(project(route, "6.49").status, "unknown") + for (const ports of [[], [{ name: "wan", running: "false" }], [{ name: "wan", running: "true" }, { name: "wan", running: "true" }]]) assert.equal(project(route, "7.24.5", ports).items[0].status, "unknown") + for (const tables of [[], [{ name: "main", fib: "false" }]]) assert.equal(project(route, "7.24.5", ports, tables).items[0].status, "unknown") + assert.equal(projectRoutingImmediateGateways("7.24.5", [route, route], ports, tables).status, "unknown") +}) +test("actual topology reader uses fixed GETs/fences/signal/wire bound and publishes no raw RIB", async () => { + const calls: string[] = []; let fences = 0 + const client = { async get(path: string, timeout?: number, signal?: AbortSignal, bytes?: number, beforeDispatch?: () => Promise) { + assert(timeout! <= 10000); assert.equal(bytes, 1048576); assert(signal); await beforeDispatch?.(); calls.push(path) + return structuredClone(path === "/system/resource" ? [{ version: "7.24.5" }] : path === "/interface" ? ports : path === "/routing/table" ? tables : path === "/routing/route" ? [route] : []) as T + } } + const result = await readRoutingTopology(client, async () => { fences++ }, new AbortController().signal) + assert.equal(calls.length, 11); assert.equal(result.routeResolution.items[0].status, "observed"); assert(fences >= calls.length * 3) + assert(!JSON.stringify(result).includes("NEVER_PRIVATE")); assert.equal(result.observedPath, false); assert.equal(result.routeResolution.captureAtomic, false) +}) +test("post-read access failure cannot be swallowed as provider unknown", async () => { + let denied = false + const client = { async get() { denied = true; return [] as T } } + await assert.rejects(readRoutingTopology(client, async () => { if (denied) throw Error("revoked") }), /revoked/) +}) + +test("cancellation during the last asynchronous publication fence refuses the result", async () => { + const cancel = new AbortController(); let fences = 0 + const client = { async get(path: string) { return (path === "/system/resource" ? [{ version: "7.24.5" }] : path === "/interface" ? ports : path === "/routing/table" ? tables : path === "/routing/route" ? [route] : []) as T } } + await assert.rejects(readRoutingTopology(client, async () => { if (++fences === 23) { await Promise.resolve(); cancel.abort() } }, cancel.signal), /Topology read cancelled/) + assert.equal(fences, 23) +}) + +test("actual RouterOS FIB switch encoding is distinct from an absent or invalid table", () => { + const native = [{ ".id": "*0", name: "main", dynamic: "true", fib: "", invalid: "false" }] + assert.equal(project(route, "7.24.5 (stable)", ports, native).items[0].status, "observed") + for (const table of [{ name: "main" }, { name: "main", fib: "false" }, { name: "main", fib: "", invalid: "true" }, { name: "main", fib: "", disabled: "true" }]) assert.equal(project(route, "7.24.5", ports, [table]).items[0].status, "unknown") +}) diff --git a/backend/src/services/routing-topology-observation.ts b/backend/src/services/routing-topology-observation.ts new file mode 100644 index 00000000..25c6a5e3 --- /dev/null +++ b/backend/src/services/routing-topology-observation.ts @@ -0,0 +1,82 @@ +import { isConfirmedRoutingFibTable } from "./routing-table-fib.js" +import { isIP } from "node:net" +import type { MikrotikClient } from "./mikrotik.js" + +type Row = Record +const paths = { interfaces: "/interface", ipv4: "/ip/route", ipv6: "/ipv6/route", tables: "/routing/table", rules: "/routing/rule", vrfs: "/ip/vrf", ospfNeighbors: "/routing/ospf/neighbor", bgpSessions: "/routing/bgp/session" } as const +const flag = (value: unknown) => value === true || value === "true" || value === "yes" ? true : value === false || value === "false" || value === "no" ? false : undefined +const literal = (value: unknown, max = 200): value is string => typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value) +const rows = (raw: unknown): Row[] => { + const values = Array.isArray(raw) ? raw : [raw] + if (values.length > 10000 || values.some(row => !row || typeof row !== "object" || Array.isArray(row))) throw Error("Unsupported rows") + return values as Row[] +} +const limits = { semanticProof: false, recursiveChainVerified: false, routeOriginVerified: false, forwardingVerified: false } as const +/** Project documented immediate-gw only. Numeric nexthop/link/interface indexes + * and BGP peer-cache/session namespaces are deliberately never joined. + * https://manual.mikrotik.com/docs/cli-reference/routing/route/ */ +export function projectRoutingImmediateGateways(version: unknown, rib: unknown, interfaces: unknown, tables: unknown) { + const unknown = (reason: string) => ({ status: "unknown" as const, reason, items: [], truncated: false, ...limits }) + if (!literal(version, 128) || !/^7\.2[0-6](?:\.\d{1,3})?(?: \((?:stable|long-term)\))?$/.test(version)) return unknown("unsupported-routeros-version") + let routes: Row[], ports: Row[], catalogue: Row[] + try { routes = rows(rib); ports = rows(interfaces); catalogue = rows(tables) } catch { return unknown("unavailable-or-malformed-catalogue") } + if (routes.some(row => !literal(row[".id"], 100)) || new Set(routes.map(row => row[".id"])).size !== routes.length) return unknown("ambiguous-route-identity") + const selected = routes.filter(row => ["ip", "ip4", "ipv6", "ip6"].includes(String(row.afi))) + const items = selected.slice(0, 500).map(row => { + const identity = { nativeId: String(row[".id"]), ...limits } + const fail = (reason: string) => ({ ...identity, status: "unknown" as const, reason }) + const prefix = row["dst-address"], table = row["routing-table"] + if (!literal(prefix) || !literal(table)) return fail("unconfirmed-prefix-or-table") + const [address, mask, extra] = prefix.split("/"), family = isIP(address) + if (!family || extra !== undefined || !/^\d+$/.test(mask ?? "") || Number(mask) > (family === 4 ? 32 : 128) || family !== (["ip", "ip4"].includes(String(row.afi)) ? 4 : 6)) return fail("unconfirmed-route-family") + if (flag(row.active) !== true || row.contribution !== "active" || ["disabled", "filtered", "unreachable", "blackhole"].some(key => row[key] !== undefined && flag(row[key]) !== false)) return fail("route-not-confirmed-active") + if (row.ecmp !== undefined && flag(row.ecmp) !== false) return fail("multipath-selection-unknown") + const matchingTables = catalogue.filter(entry => entry.name === table) + if (matchingTables.length !== 1 || !isConfirmedRoutingFibTable(matchingTables[0])) return fail("unconfirmed-fib-table") + const immediate = row["immediate-gw"] + if (!literal(immediate)) return fail("immediate-gateway-unavailable") + const endpoint = /^([^%@,\s]+)%([^%@,]+)(?:@([^%@,]+))?$/.exec(immediate) + const connected = flag(row.connect) === true + const iface = endpoint?.[2] ?? (connected && !/[%@,]/.test(immediate) ? immediate : undefined) + if (!iface || endpoint && (isIP(endpoint[1]) !== family || endpoint[3] !== undefined && endpoint[3] !== table)) return fail("unconfirmed-immediate-endpoint") + const matchingPorts = ports.filter(entry => entry.name === iface) + if (matchingPorts.length !== 1 || flag(matchingPorts[0].running) !== true || matchingPorts[0].disabled !== undefined && flag(matchingPorts[0].disabled) !== false) return fail("unconfirmed-immediate-interface") + return { ...identity, status: "observed" as const, family: family === 4 ? "ipv4" as const : "ipv6" as const, prefix, table, + immediateGateway: { address: endpoint?.[1] ?? null, interface: iface }, observationScope: "routeros-reported-final-next-hop" as const } + }) + return { status: "observed" as const, routerOsVersion: version, items, truncated: selected.length > 500, ...limits } +} + +/** Existing topology reader: bounded GETs, same caller/endpoint fence at actual + * dispatch and publication. Failure remains per-resource observational unknown. */ +export async function readRoutingTopology(client: Pick, beforeRead: () => Promise, signal?: AbortSignal) { + const deadline = Date.now() + 30000 + const acquire = async (path: string) => { + await beforeRead() + const remaining = deadline - Date.now() + if (remaining <= 0 || signal?.aborted) throw Error("Topology read cancelled") + try { + const value = await client.get(path, Math.min(10000, remaining), signal, 1048576, beforeRead) + await beforeRead() + return { available: true as const, value: rows(value) } + } catch { + await beforeRead() + return { available: false as const } + } + } + const first = await acquire("/system/resource") + const captured: Partial> = {}, resources: Record = {} + for (const [key, path] of Object.entries(paths) as Array<[keyof typeof paths, string]>) { + const result = await acquire(path) + if (result.available) { + captured[key] = result.value + resources[key] = { items: result.value.slice(0, 500), truncated: result.value.length > 500, observed: true } + } else resources[key] = { items: [], observed: false, reason: "Устройство не подтвердило возможность чтения" } + } + const rib = await acquire("/routing/route"), last = await acquire("/system/resource") + await beforeRead() + if (signal?.aborted || Date.now() >= deadline) throw Error("Topology read cancelled") + const version = first.available && last.available && first.value.length === 1 && last.value.length === 1 && first.value[0].version === last.value[0].version ? first.value[0].version : undefined + const routeResolution = projectRoutingImmediateGateways(version, rib.available ? rib.value : undefined, captured.interfaces, captured.tables) + return { observedAt: new Date().toISOString(), observedPath: false, routeResolution: { ...routeResolution, captureAtomic: false, sourceGenerationIssued: false }, resources } +} diff --git a/backend/src/services/shared-database-gate.ts b/backend/src/services/shared-database-gate.ts index 1366ebb6..86070993 100644 --- a/backend/src/services/shared-database-gate.ts +++ b/backend/src/services/shared-database-gate.ts @@ -1,4 +1,14 @@ import { DatabaseRequestGate } from "./database-request-gate.js" +import { createDatabaseSchemaReadiness } from "./system-database-operation.js" // One lifetime gate for HTTP handlers, MCP calls, runners and background controllers. export const sharedDatabaseGate = new DatabaseRequestGate() + +// Same process-local readiness boundary for consumers and request admission. +// Exclusive admin restore remains available to repair an unconfirmed schema. +const schemaReadiness = createDatabaseSchemaReadiness() +export const databaseSchemaReadiness = { + beginReplacement() { schemaReadiness.beginReplacement(); sharedDatabaseGate.closeAdmission() }, + confirmSchema() { schemaReadiness.confirmSchema(); sharedDatabaseGate.openAdmission() }, + assertReady: schemaReadiness.assertReady, +} diff --git a/backend/src/services/system-database-backup.ts b/backend/src/services/system-database-backup.ts index c52ba2c6..2ad42225 100644 --- a/backend/src/services/system-database-backup.ts +++ b/backend/src/services/system-database-backup.ts @@ -4,7 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks" import { runPrivateDatabaseDump } from "./database-dump-connection.js" import { DATABASE_CONTROL_DUMP_EXCLUDE_SCHEMA_ARG } from "./database-control-archive-boundary.js" import { open } from "node:fs/promises" -import { sharedDatabaseGate } from "./shared-database-gate.js" +import { databaseSchemaReadiness as schemaReadiness, sharedDatabaseGate } from "./shared-database-gate.js" import { assertDatabaseRestoreAllowed } from "./config-mutation-policy.js" import { accessGeneration, invalidateMcpAccess, mcpStateDirectory } from "../modules/mcp/security.js" import { executeMcpDatabaseMaintenance } from "../modules/mcp/database-maintenance.js" @@ -17,7 +17,7 @@ import { executeSystemDatabaseRestore, validateSystemDatabaseRestoreArchive, typ import { env } from "../config.js" import { pool } from "../db/index.js" import { withOperationLock } from "../db/operation-lock.js" -import { createDatabaseSchemaReadiness, executeDatabaseOperation, type DatabaseOperationWarnings } from "./system-database-operation.js" +import { executeDatabaseOperation, type DatabaseOperationWarnings } from "./system-database-operation.js" import { restartSchedulerAfterDatabaseOperation, stopSchedulerForDatabaseOperation } from "./scheduler.js" import { pauseRipeBackgroundWork, resumeRipeBackgroundWork } from "./traffic-flow-ripe.js" import { invalidateSystemDatabaseCaches } from "./system-database-cache.js" @@ -29,9 +29,9 @@ import { const BACKUP_LOCK_KEY = 8_723_102 const backupAdmission = new AsyncLocalStorage<{ active: boolean }>() -// Shared across operations: a later export must not restart consumers after an -// unconfirmed restore. This latch intentionally makes no cross-process claim. -const schemaReadiness = createDatabaseSchemaReadiness() +const restoreAdmission = new AsyncLocalStorage<{ active: boolean }>() +// The shared readiness latch also refuses new request/worker admission after +// unconfirmed replacement; startup recovery supplies the cross-process guard. /** Acquire before worker/config locks so admitted requests can drain without a lock inversion. */ export function withSystemDatabaseBackupAdmission(work: () => Promise) { return sharedDatabaseGate.exclusive(async () => { @@ -40,6 +40,14 @@ export function withSystemDatabaseBackupAdmission(work: () => Promise) { }) } +/** Terminal MCP claim and restore share one exclusive admission from first SQL. */ +export function withSystemDatabaseRestoreAdmission(work: () => Promise) { + return sharedDatabaseGate.exclusive(async () => { + const admission = { active: true } + try { return await restoreAdmission.run(admission, work) } finally { admission.active = false } + }) +} + function fmtTimestamp(date = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0") return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}` @@ -70,7 +78,7 @@ function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer }) } -async function withDatabaseOperation(fn: () => Promise, admittedBackup = false): Promise { +async function withDatabaseOperation(fn: () => Promise, admittedKind?: "backup" | "restore"): Promise { const execute = () => withOperationLock(() => pool.connect(), BACKUP_LOCK_KEY, () => executeDatabaseOperation(fn, { stopListener: stopTrafficFlowListenerForDatabaseOperation, beforeRestart: schemaReadiness.assertReady, @@ -84,7 +92,8 @@ async function withDatabaseOperation(fn: () => Promise, adm await restartSchedulerAfterDatabaseOperation() }, }), "Операция с базой приложения уже выполняется") - return admittedBackup && backupAdmission.getStore()?.active ? execute() : sharedDatabaseGate.exclusive(execute) + const admission = admittedKind === "backup" ? backupAdmission.getStore() : admittedKind === "restore" ? restoreAdmission.getStore() : undefined + return admission?.active ? sharedDatabaseGate.withinAdmission(execute) : sharedDatabaseGate.exclusive(execute) } export async function exportSystemDatabaseBackup(options?: { signal?: AbortSignal; authorize?: () => Promise }): Promise<{ filename: string; buffer: Buffer } & DatabaseOperationWarnings> { @@ -100,7 +109,7 @@ export async function exportSystemDatabaseBackup(options?: { signal?: AbortSigna filename: `mikrotik-manager_${fmtTimestamp()}.dump`, buffer, } - }, true) + }, "backup") } /** REST and MCP share the same export event; event failure never erases a completed dump. */ @@ -123,7 +132,7 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer, options?: { be await accessGeneration() const journal = await open(path.join(mcpStateDirectory(), "database-maintenance.jsonl"), "a", 0o600), operationId = randomUUID() const record = async (state: string) => { await journal.writeFile(JSON.stringify({ operationId, state, at: new Date().toISOString() }) + "\n"); await journal.sync() } - return executeMcpDatabaseMaintenance(async () => { + const outcome = await executeMcpDatabaseMaintenance(async () => { const result = await executeSystemDatabaseRestore(buffer, env.DATABASE_URL, { allocate: async () => { const tempDir = path.join(os.tmpdir(), "mmapp-db-restore") @@ -142,11 +151,15 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer, options?: { be remove: async file => { await Promise.all([rm(file, { force: true }), rm(`${file}.sql`, { force: true })]) }, invalidate: invalidateSystemDatabaseCaches, }) - try { await applySqlMigrations(pool); schemaReadiness.confirmSchema() } + try { await applySqlMigrations(pool) } catch { result.migrationError = "База восстановлена, но миграции приложения не завершены. Требуется проверка схемы; не повторяйте restore." } return result }, { record, invalidateAccess: invalidateMcpAccess, close: () => journal.close() }) - }) + // Neither uncertain replacement nor unconfirmed external completion may + // release request admission or restart consumers. Explicit restore can repair. + if (!outcome.migrationError && !outcome.maintenanceError) schemaReadiness.confirmSchema() + return outcome + }, "restore") } export async function assertSystemDatabaseRestoreTools() { diff --git a/backend/src/services/system-database-schema-readiness.test.ts b/backend/src/services/system-database-schema-readiness.test.ts index a104aad2..bcd971c1 100644 --- a/backend/src/services/system-database-schema-readiness.test.ts +++ b/backend/src/services/system-database-schema-readiness.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import Fastify from "fastify" import { test } from "node:test" import { readFile } from "node:fs/promises" import { EventEmitter } from "node:events" @@ -7,6 +8,7 @@ import os from "node:os" import path from "node:path" import { runInNewContext } from "node:vm" import ts from "typescript" +import { DatabaseRequestGate } from "./database-request-gate.js" import * as operation from "./system-database-operation.js" import * as restore from "./system-database-restore-execution.js" import * as partitionRestore from "./system-database-partition-restore.js" @@ -17,7 +19,17 @@ import * as archiveBoundary from "./database-control-archive-boundary.js" // configured pool, native subprocess, filesystem journal or network is opened. async function fixture() { const calls: string[] = [] - let failure: "decode" | "psql" | "migration" | "cleanup" | undefined + // Execute the actual shared singleton wiring in a fresh inert module. + const shared = {} as { sharedDatabaseGate: DatabaseRequestGate; databaseSchemaReadiness: ReturnType } + const sharedSource = await readFile(new URL("./shared-database-gate.ts", import.meta.url), "utf8") + const sharedCompiled = ts.transpileModule(sharedSource, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText + runInNewContext(sharedCompiled, { exports: shared, require: (name: string) => { + if (name === "./database-request-gate.js") return { DatabaseRequestGate } + if (name === "./system-database-operation.js") return operation + throw Error(`Unapproved shared import ${name}`) + } }) + const { sharedDatabaseGate: gate, databaseSchemaReadiness } = shared + let failure: "decode" | "psql" | "migration" | "cleanup" | "journal-applied" | "journal-close" | "revoke" | undefined const step = (name: string) => async () => { calls.push(name) } const spawn = (command: string, args: string[]) => { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; stdin: { end(): void } } @@ -33,7 +45,7 @@ async function fixture() { return child } const files = { - open: async () => ({ writeFile: async () => {}, sync: async () => {}, close: async () => {} }), + open: async () => { let state = ""; return { writeFile: async (raw: string) => { state = JSON.parse(raw).state }, sync: async () => { if (failure === "journal-applied" && state === "applied") throw Error("synthetic applied fsync failure") }, close: async () => { if (failure === "journal-close") throw Error("synthetic close failure") } } }, chmod: async () => {}, mkdir: async () => {}, writeFile: async () => {}, rm: async () => { if (failure === "cleanup") throw Error("synthetic cleanup failure") }, } @@ -43,9 +55,9 @@ async function fixture() { "../modules/events/service/events-service.js": { appendEvent: async () => {} }, "./database-dump-connection.js": { runPrivateDatabaseDump: async () => { throw Error("Unused private dump") } }, "./database-control-archive-boundary.js": archiveBoundary, - "./shared-database-gate.js": { sharedDatabaseGate: { exclusive: (work: () => unknown) => work() } }, + "./shared-database-gate.js": { sharedDatabaseGate: gate, databaseSchemaReadiness }, "./config-mutation-policy.js": { assertDatabaseRestoreAllowed: async () => {} }, - "../modules/mcp/security.js": { accessGeneration: async () => "fixture", invalidateMcpAccess: async () => {}, mcpStateDirectory: () => "inert-state" }, + "../modules/mcp/security.js": { accessGeneration: async () => "fixture", invalidateMcpAccess: async () => { if (failure === "revoke") throw Error("synthetic revoke failure") }, mcpStateDirectory: () => "inert-state" }, "../modules/mcp/database-maintenance.js": maintenance, "../db/migrate.js": { applySqlMigrations: async () => { calls.push("migration"); if (failure === "migration") throw Error("synthetic migration failure") } }, "./system-database-restore-execution.js": restore, @@ -63,7 +75,7 @@ async function fixture() { const source = await readFile(new URL("./system-database-backup.ts", import.meta.url), "utf8") const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText runInNewContext(compiled, { exports, require: (name: string) => { assert(name in modules, `Unapproved import ${name}`); return modules[name] }, Buffer, process: { env: {} }, Error, Date, Promise }, { filename: "system-database-backup.ts" }) - return { calls, exports, fail: (phase?: typeof failure) => { failure = phase } } + return { calls, exports, gate, fail: (phase?: typeof failure) => { failure = phase } } } const archive = Buffer.from("PGDMPinert") const restartCalls = (calls: string[]) => calls.filter(c => ["start listener", "start scheduler", "resume ripe"].includes(c)) @@ -73,23 +85,48 @@ test("actual restore leaves consumers stopped after migration failure, including const result = await f.exports.restoreSystemDatabaseBackup(archive) assert.match(String(result.migrationError), /миграции/); assert.match(String(result.schedulerError), /не подтверждена/) assert.equal(f.calls.filter(c => c === "psql").length, 1); assert.deepEqual(restartCalls(f.calls), []) + let dispatched = 0 + await assert.rejects(f.gate.request(async () => { dispatched++ }), /не подтверждена/) + assert.equal(dispatched, 0); assert.equal(f.gate.isPaused(), true) + let resumed = false + const waiter = f.gate.whenOpen().then(() => { resumed = true }) f.fail(); await f.exports.exportSystemDatabaseBackup(); assert.deepEqual(restartCalls(f.calls), []) + await Promise.resolve(); assert.equal(resumed, false) + void waiter + await assert.rejects(f.gate.request(async () => { dispatched++ }), /не подтверждена/) + assert.equal(dispatched, 0) }) test("actual restore preserves unknown replacement failure without restarting or retrying", async () => { const f = await fixture(); f.fail("psql") await assert.rejects(f.exports.restoreSystemDatabaseBackup(archive), /synthetic psql failure/) assert.equal(f.calls.filter(c => c === "psql").length, 1); assert(!f.calls.includes("migration")); assert.deepEqual(restartCalls(f.calls), []) + assert.equal(f.gate.isPaused(), true) + let dispatched = 0 + await assert.rejects(f.gate.request(async () => { dispatched++ }), /не подтверждена/) + assert.equal(dispatched, 0) + // Repair uses the existing exclusive service boundary, outside request admission. + let resumed = false + const waiter = f.gate.whenOpen().then(() => { resumed = true }) + await Promise.resolve(); assert.equal(resumed, false) + f.fail("revoke"); await assert.rejects(f.exports.restoreSystemDatabaseBackup(archive), /synthetic revoke failure/) + await Promise.resolve(); assert.equal(resumed, false); assert.equal(f.calls.filter(c => c === "psql").length, 1) + await assert.rejects(f.gate.request(async () => { dispatched++ }), /не подтверждена/) + f.fail(); await f.exports.restoreSystemDatabaseBackup(archive) + await waiter; assert.equal(resumed, true); assert.equal(f.gate.isPaused(), false) + await f.gate.request(async () => { dispatched++ }); assert.equal(dispatched, 1) }) test("archive decoding failure before target replacement permits normal restart", async () => { const f = await fixture(); f.fail("decode") await assert.rejects(f.exports.restoreSystemDatabaseBackup(archive), /synthetic decode failure/) assert(!f.calls.includes("psql")); assert.deepEqual(restartCalls(f.calls), ["start listener", "resume ripe", "start scheduler"]) + await f.gate.request(async () => {}) }) test("acknowledged schema restarts consumers even when temporary cleanup fails", async () => { const f = await fixture(); f.fail("cleanup") const result = await f.exports.restoreSystemDatabaseBackup(archive) assert.match(String(result.cleanupError), /временный файл/); assert.equal(result.migrationError, undefined) assert(f.calls.indexOf("migration") < f.calls.indexOf("start listener")) + await f.gate.request(async () => {}) assert.deepEqual(restartCalls(f.calls), ["start listener", "resume ripe", "start scheduler"]) }) test("ordinary backup retains its restart behavior", async () => { @@ -103,5 +140,45 @@ test("a separate explicit restore clears the latch only after acknowledged migra const result = await f.exports.restoreSystemDatabaseBackup(archive) assert.equal(result.migrationError, undefined); assert.equal(result.schedulerError, undefined) assert(f.calls.indexOf("migration") < f.calls.indexOf("start listener")) + await f.gate.request(async () => {}) assert.deepEqual(restartCalls(f.calls), ["start listener", "resume ripe", "start scheduler"]) }) + +for (const phase of ["journal-applied", "journal-close"] as const) test(`acknowledged replacement with ${phase} stays closed until explicit repair`, async () => { + const f = await fixture(); f.fail(phase) + const result = await f.exports.restoreSystemDatabaseBackup(archive) + assert.equal(typeof result.maintenanceError, "string"); assert.deepEqual(restartCalls(f.calls), []) + let dispatched = 0, resumed = false + await assert.rejects(f.gate.request(async () => { dispatched++ }), /не подтверждена/) + const waiting = f.gate.whenOpen().then(() => { resumed = true }) + await Promise.resolve(); assert.equal(resumed, false); assert.equal(dispatched, 0) + f.fail(); await f.exports.restoreSystemDatabaseBackup(archive); await waiting + await f.gate.request(async () => { dispatched++ }); assert.equal(dispatched, 1) +}) + +test("actual HTTP gate preserves only the existing backup/restore repair exclusions", async () => { + const f = await fixture(), app = Fastify() + const plugin = {} as { installDatabaseRequestGate(app: ReturnType): void } + const source = await readFile(new URL("../plugins/database-request-gate.ts", import.meta.url), "utf8") + const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText + runInNewContext(compiled, { exports: plugin, require: (name: string) => { + if (name === "../services/shared-database-gate.js") return { sharedDatabaseGate: f.gate } + if (name === "../services/config-mutation-policy.js") return { withConfigurationActor: (_actor: string, work: () => unknown) => work() } + throw Error(`Unapproved HTTP adapter import ${name}`) + } }) + plugin.installDatabaseRequestGate(app) + let ordinarySql = 0 + app.get("/api/system/database/status", async () => { ordinarySql++; return { ok: true } }) + app.post("/api/system/database/restore", async () => f.exports.restoreSystemDatabaseBackup(archive)) + app.get("/api/system/database/backup", async () => f.exports.exportSystemDatabaseBackup()) + try { + f.fail("psql") + assert.equal((await app.inject({ method: "POST", url: "/api/system/database/restore" })).statusCode, 500) + assert.equal((await app.inject("/api/system/database/status")).statusCode, 503); assert.equal(ordinarySql, 0) + f.fail() + assert.equal((await app.inject("/api/system/database/backup")).statusCode, 200) + assert.equal((await app.inject("/api/system/database/status")).statusCode, 503); assert.equal(ordinarySql, 0) + assert.equal((await app.inject({ method: "POST", url: "/api/system/database/restore" })).statusCode, 200) + assert.equal((await app.inject("/api/system/database/status")).statusCode, 200); assert.equal(ordinarySql, 1) + } finally { await app.close() } +}) diff --git a/docs/mcp-current-progress.json b/docs/mcp-current-progress.json new file mode 100644 index 00000000..704d5964 --- /dev/null +++ b/docs/mcp-current-progress.json @@ -0,0 +1,3393 @@ +{ + "date": "2026-11-03", + "fullPlanComplete": false, + "remainingDirectionCount": 7, + "remainingDirections": [ + { + "id": "POLL", + "name": "Эксплуатационные границы полного server poll" + }, + { + "id": "DB", + "name": "Доверенное восстановление Рё legacy conversion" + }, + { + "id": "ROUTING", + "name": "Контроллер маршрутизации Рё reserved recovery" + }, + { + "id": "NATIVE", + "name": "VRF/VPN/NetFlow interoperability Рё трафик" + }, + { + "id": "PATHS", + "name": "Независимый трёхузловой путь Рё failover" + }, + { + "id": "EXTERNAL-UI", + "name": "Live auth/AI clients/UI Рё нагрузка РґРѕ 2 ГБ" + }, + { + "id": "FINAL", + "name": "Финальная интеграционная приёмка" + } + ], + "latestCompleted": "Current44 genuine owned PostgreSQL/HTTP accepted all seven restore-audit cases, including real advisory contention and real COMMIT with injected lost response. Actual exec0, physical native retirement and exact cleanup confirmed. Shared native stop works without taskkill.", + "latestReport": "docs/mcp-restore-audit-genuine-acceptance-20261103.json", + "latestDbNativeReport": "docs/mcp-db-registry-read-contract-acceptance-20261101.json", + "speedTasksRemaining": 0, + "nextPriority": "Finish current MCP changes and Linux VPS acceptance for the existing two-CHR scheme. After that ask the user for the third CHR and continue three-node acceptance. RAM and target performance acceptance are Linux VPS only.", + "routeCoverageInventory": { + "mediated": 200, + "excluded": 63, + "pending": 0 + }, + "manualCollectors": { + "complete": 4, + "total": 4, + "remaining": 0 + }, + "nativeWorker": { + "actualMainIpcUdpSqlAccepted": true, + "actualGroups": 8, + "datagrams": 10, + "bytes": 18600, + "packets": 78, + "ipfixDomainAndActualSourcePortSqlAccepted": true, + "actualCurrentProtocolLog": "tmp/mcp-native-protocol-worker-ipc-scope-owned-20261029.log", + "currentParserSqlRepeatAccepted": true, + "currentParserSqlRepeatLog": "tmp/mcp-flow-current-parser-owned-retry-20261029.log", + "fullNativeComplete": false, + "remainingBoundary": "Real CHR VRF/VPN traffic, exporter interoperability and recursive native acquisition; whole-installation load remains separate", + "activeSettingsPurgeGroups": 4, + "activeRebuildGroups": 5, + "inFlightSqlFlushes": 3, + "localVerificationBlocksAccepted": [ + "main/Worker IPC lifecycle", + "active settings/purge services", + "active rebuild service" + ], + "currentPrimaryRepeatStatus": "Prior1896 core SQL/HTTP+restore accepted; subsequent2 Worker product files andowned regression applied after actual12/unit21/build0. Final broader current integration remains separate.", + "localStartupRetirementFaultsAccepted": true, + "localStartupRetirementFaultsReport": "docs/mcp-flow-worker-startup-faults-source-applied-20261101.json", + "publicActiveCollectorAccepted": true, + "publicActiveCollectorReport": "docs/mcp-flow-public-active-source-applied-20261101.json" + }, + "routing": { + "forwardConfigurationRoundCases": 5, + "internalHandoffComponentTests": 10, + "reservationAdmissionSqlCases": 15, + "publicAutoRecoveryEnabled": false, + "fullReservedRuntimeAccepted": false, + "authenticatedLogsIntervalAccepted": false, + "directionComplete": false, + "numberedSchema": "0040_mcp_routing_reservations", + "numberedSchemaAccepted": true, + "numberedSchemaSqlGroups": 6, + "userConfirmedLogSource": "RouterOS REST /log only", + "externalCollectorAvailable": false, + "restLogCoverage": "bounded-ring-events-no-authenticated-complete-interval", + "restLogCoverageUnknownRefusalVerifiedInSource": true, + "activationBaselineSourceApplied": true, + "activationBaselineReport": "docs/mcp-routing-baseline-progress-20261029.json", + "currentPrimaryRepeatStatus": "PASS current42/v3 frozen primary/restore; public autoRecovery remains disabled without complete authenticated logs", + "publicOverlapActivationRaceAccepted": true, + "durableLedgerProposal": { + "status": "owned-pg-storage-primitive-accepted-proposal-not-installed", + "sqlGroups": 10, + "postgres": "18.4", + "report": "docs/mcp-routing-ledger-durable-acceptance-20261031.json", + "authenticatedSourceAccepted": false, + "allWriterAdmissionAccepted": false, + "dispatchAllowed": false + }, + "logContinuityDocumentationReview": { + "date": "2026-11-02", + "documentedRouterOsVersion": "7.26", + "report": "docs/mcp-routeros-log-continuity-boundary-20261102.md", + "actualChrCapabilitiesTested": false, + "authenticatedCompleteIntervalAccepted": false, + "ordinarySyslogAloneIsNotProof": true + } + }, + "databaseSchema": { + "manifestFiles": 42, + "controlledMigrationSqlGroups": 8, + "controlledReferenceSqlGroups": 9, + "old39FilesByteIdentical": true, + "readyAuthorized": false, + "fullRegressionWithRestore": "Last full schema42/v3 baseline PASS; subsequent snapshot comparator fix has targeted REST/MCP/history/management/build/lint PASS, full current repeat pending", + "topLevelBackendProfilesIncludingLogsSql": 37, + "singleCleanFullRunClaimed": false, + "old40FilesByteIdentical": true, + "postPollBatchSourceFullRegression": "failed-primary-netflow-settings-observation-timeout; 37profiles/restore not reached; cause unproven; earlier41/37 historical run preserved", + "currentFullRegressionLog": "tmp/mcp-current-full-backend-20261029.log", + "currentPrimaryRepeatLog": "tmp/mcp-policy-primary-durable-20261029.log", + "currentPrimaryRepeatStatus": "Historical baseline PASS only; current policy-loan product change requires a new primary/restore run.", + "commitAcknowledgementActualSqlCases": 17, + "abortedCommitRollbackCases": 3, + "old41FilesByteIdentical": true, + "historicalFullRegressionWithRestore": "pass-41-migrations-37-top-level-profiles-including-logs-sql", + "currentNumberedEpochRestoreReport": "docs/mcp-poll-epoch-numbered-acceptance-20261030.json" + }, + "telemetryConfiguration": { + "operationVersions": 2, + "affectedSettings": 3, + "componentTests": 9, + "actualSqlRuntimeCases": 9, + "metadataChangesTolerated": true, + "opaqueConfigurationDriftRefused": true, + "fullIntegrationWithSchedulersAccepted": true + }, + "pollJournalSizing": { + "report": "docs/mcp-poll-journal-sizing-acceptance-20261029.json", + "journalRows": 308, + "fullJsonBytes": 138941, + "gzipBytes": 59075, + "losslessRoundTripAccepted": true, + "productionCapacityAccepted": false, + "compactionImplemented": true + }, + "pollTerminalStorage": { + "report": "docs/mcp-poll-terminal-storage-acceptance-20261029.json", + "schemaSqlGroups": 3, + "genuineMcpSourcesAccepted": 4, + "normalSqlGroups": 4, + "ciphertextBytes": 126076, + "sourceWiredToExistingWorker": true, + "actualAckLossAccepted": true, + "actualHistoricalCurrentAuthAccepted": true, + "capacity336ActualAccepted": false, + "directionComplete": false, + "actualDefaultWorkerAccepted": true, + "archivesCreated": 3, + "actualClaimAckLossFullChargePreserved": true, + "freshUnwrappedRepeatAccepted": true + }, + "memoryAcceptance": { + "report": "docs/mcp-memory-collector-baseline-20261029.json", + "wholeInstallation2GbAccepted": false, + "collectorTests": 23, + "actualHostCgroupV2Accepted": true, + "currentBranchLoadAccepted": false, + "existingContainerCount": 4, + "newOperatorWindowsConsumed": 0, + "compiledProfileReport": "docs/mcp-poll-cost-reduction-progress-20261029.json", + "compiledIsolatedProfileAccepted": true, + "compiledIsolatedOutcome": "applied-148publications-unwrapped", + "provisionalBackendPeakRssKiB": 260060, + "provisionalPeakIsCleanAcceptance": false, + "compiledSinglePollRuntimeAccepted": true, + "localNodeVersion": "v25.5.0", + "localPlatform": "win32", + "dockerNode22LinuxAccepted": false, + "latestColdPollAttemptAccepted": false, + "latestColdPollAttemptLog": "tmp/mcp-poll-archive-process-crash-owned-20261029.log", + "latestComponentReport": "docs/mcp-netflow-memory-progress-20261029.json", + "candidateCompose2GbAccepted": false, + "latestCandidateDiagnostics": "docs/mcp-memory-2gb-candidate-diagnostics-20261029.json" + }, + "mostRecentOpenFinding": "Constrained old192/semi16 local app+Worker peak470.46MiB accepted only as component measurement; earlier standard-admission192 failures retained. Whole LinuxNode22 installation <=2GB, cold poll stability/capacity336, DB all-writer/source-generation/READY and routing event continuity remain open.", + "pollCostReduction": { + "report": "docs/mcp-poll-cost-reduction-progress-20261029.json", + "actualSqlLockGroups": 3, + "managementProfileAccepted": true, + "pairedRuntimeAccepted": false, + "unwrappedCompiledRuntimeAccepted": true, + "currentSourceFullRegressionAccepted": false, + "pairedDiagnosticCompleted": true, + "unwrappedLastPublication": 115, + "coherentPrincipalSqlGroups": 10, + "metadataAndJournalSqlGroups": 4, + "unwrappedFullPublications": 148, + "unwrappedElapsedMs": 150868.9248, + "unwrappedPeakRssKiB": 258316 + }, + "latestPollV2Report": "docs/mcp-poll-v2-progress-20261029.json", + "latestHeartbeatReport": "docs/mcp-heartbeat-backpressure-progress-20261029.json", + "latestFixtureExpiryReport": "docs/mcp-integration-fixture-expiry-progress-20261029.json", + "latestPrimaryRun": { + "accepted": true, + "state": "accepted", + "log": "tmp/mcp-primary-v2-heartbeat-expiry-owned-20261029.log", + "pins": "tmp/mcp-primary-v2-heartbeat-expiry-pins-20261029.json", + "report": "docs/mcp-primary-v2-heartbeat-acceptance-20261029.json" + }, + "routingBaselineDraft": { + "integrated": false, + "mainServiceCompatibilityAccepted": false, + "boundedSelectActualSqlCases": 9, + "vmCases": 69, + "overlayTypeErrors": 0, + "review": "tmp/routing-baseline-bounded-20261029/REVIEW.md" + }, + "latestPrimaryRestoreReport": "docs/mcp-current-combined-primary-acceptance-20261029.json", + "latestNativeReadBoundsReport": "docs/mcp-native-read-bounds-progress-20261029.json", + "fullPrimaryRestoreAfterCurrentChangesAccepted": false, + "latestRoutingBaselineReport": "docs/mcp-routing-baseline-progress-20261029.json", + "latestDatabaseImportReport": "docs/mcp-database-import-control-acceptance-v1.json", + "latestNetflowMemoryReport": "docs/mcp-netflow-memory-progress-20261029.json", + "currentSmallBlockBatch": { + "scope": "Recent implementation/verification slices only, not a reduced definition of the whole plan", + "accepted": [ + "NATIVE.R1 aggregate admission and atomic quality receipts", + "NATIVE.R2 shared read ownership, scoped quality and bounded source decode", + "DB.W1 importer marker/sequence admission and real COMMIT acknowledgement", + "RT3.A activation baseline and exact prewrite identity, main/scoped/manual inverse", + "RAM.M1 pinned Windows512 component measurement only", + "DB.V1 logical schema-v3 seeded restore and signed-reference only; no source generation or READY", + "RAM.M2a opt-in two-reader admission, cold settings SQL and affected regressions; Windows component measurement only", + "EXT2.A separate installed SDK process HTTP/STDIO lifecycle and local OAuth PKCE; synthetic portal only", + "UI.A three permanent Chrome/auth-enabled synthetic permission and lifecycle browser cases", + "POLL.C1a conditional exact-source fusion and one current unwrapped cold143 run;49tests/7public races/10SQL groups/management", + "POLL.C1b three independent current Windows cold143 repeats within original180s; target platform remains open", + "POLL.C1c bounded exact-SQL protocol preparation;47unit/13SQL/7public races/management/build/lint and one current Windows cold113202ms", + "POLL.V1 additional actual SQL coverage:12MCP refusals, both original ordered locks and revocation while waiting; unsuccessful runtime candidate removed", + "POLL.V2 explicit actual-initDatabase profile: one Windows42737ms and one LinuxNode22 87289ms complete receipt; no cold/whole2GB claim", + "POLL.C2a immutable expected-input derivation;36unit/7public afterIO and normal startup Windows30708ms/Linux80543ms; cold and whole2GB remain open", + "POLL.C2b bounded coherent policy source;4unit/14SQL/7public afterIO and normal Windows48849ms/Linux82374ms; cold and whole2GB remain open" + ], + "open": [ + "RAM.M2b target LinuxNode22 full-installation <=2GB workload acceptance", + "POLL.C1 target Linux cold poll and capacity336; local historical Windows cold143 series passed" + ] + }, + "latestLogicalSchemaReport": "docs/mcp-logical-schema-v3-acceptance-20261029.json", + "latestConstrainedFlowReport": "docs/mcp-constrained-flow-profile-acceptance-20261029.json", + "latestExternalProcessReport": "docs/mcp-external-process-acceptance-20261029.json", + "currentPrimaryStatusFile": "tmp/mcp-poll-v3-final-v5-20261030-status.json", + "currentPrimaryPinsFile": "tmp/mcp-poll-v3-final-v5-20261030-pins.json", + "latestAuthUiReport": "docs/mcp-auth-ui-acceptance-20261029.json", + "executionMode": "sequential in this session; no subagents per latest user instruction", + "primaryAcceptedBeforeLatestPollChange": "docs/mcp-current-combined-primary-acceptance-20261029.json", + "latestAppliedPollChange": { + "date": "2026-10-29", + "review": "tmp/poll-conditional-fusion-20261029/REVIEW.md", + "pins": "tmp/mcp-poll-conditional-v2-applied-pins-20261029.json", + "runtimeFiles": 8, + "permanentTestFiles": 2, + "componentTests": 49, + "buildExitCode": 0, + "lintExitCode": 0, + "currentUnwrappedColdLog": "tmp/mcp-poll-conditional-current-unwrapped-owned-20261029.log", + "currentUnwrappedColdStatus": "PASS137539ms,143maintenance/147publications/304rows, processpeak249.875MiB; singleWindowsrun", + "historicalDiagnosticColdMs": 143915, + "diagnosticIsolatedLatencyAccepted": false + }, + "latestPollConditionalReport": "docs/mcp-poll-conditional-progress-20261029.json", + "latestLocalColdStabilityReport": "docs/mcp-poll-local-cold-stability-20261029.json", + "latestLinuxPollReport": "docs/mcp-poll-policy-loan-linux-20261030.json", + "targetLinuxPollAccepted": false, + "latestBoundedPrincipalPlanReport": "docs/mcp-bounded-principal-plan-acceptance-20261029.json", + "wholeInstallation2GbAccepted": false, + "latestRejectedPollOptimization": "docs/mcp-poll-owned-validation-review-20261029.json", + "latestNormalStartupPollReport": "docs/mcp-poll-normal-startup-acceptance-20261029.json", + "targetLinuxNormalStartupPollAccepted": true, + "targetLinuxColdPollAccepted": false, + "latestImmutableSubjectReport": "docs/mcp-poll-immutable-subject-acceptance-20261029.json", + "latestCpuDiagnosticReport": "docs/mcp-poll-cpu-diagnostic-20261029.json", + "currentNormalStartupPollReport": "docs/mcp-poll-policy-loan-linux-20261030.json", + "latestPollPolicySourceReport": "docs/mcp-poll-policy-source-acceptance-20261029.json", + "latestCurrentPrimaryReport": "docs/mcp-poll-v3-full-local-acceptance-20261030.json", + "latestPollSqlCountDiagnosticReport": "docs/mcp-poll-sql-count-diagnostic-20261030.json", + "current37ProfileMatrix": { + "status": "pending-after-v4-private-wiring", + "accepted": false, + "historicalLastReport": "docs/mcp-poll-v3-full-local-acceptance-20261030.json" + }, + "latest37ProfileFailureReport": "docs/mcp-policy-regression-failure-20261030.json", + "currentTargetedRegressionRecheck": { + "status": "accepted", + "accepted": true, + "collectorWriterSqlCases": 28, + "isolatedDefaultPublicPoll": true, + "report": "docs/mcp-collector-shared-regression-acceptance-20261030.json" + }, + "primaryProductionCodeStillCurrent": false, + "primaryTestScaffoldingChangedAfterRun": [], + "latestCoverageReviewReport": "docs/mcp-policy-regression-v2-coverage-review-20261030.json", + "currentOwnedDisabledCoverage": { + "status": "targeted-pass", + "checks": 39, + "zeroRouterOsContacts": true, + "log": "tmp/mcp-config-sync-owned-coverage-v2-recheck-20261030.log" + }, + "latest37ProfileAcceptanceReport": "docs/mcp-poll-v3-full-local-acceptance-20261030.json", + "primaryContinuity": { + "allProductSourcesUnchangedSincePrimary": false, + "changedSourceFiles": [ + "backend/src/modules/mcp/server-poll-http.sql-helper.ts", + "backend/src/services/server-poll-execution-model.ts", + "backend/src/services/server-poll-maintenance-planner.test.ts", + "backend/src/services/server-poll-rest-authority.sql-helper.ts" + ], + "report": "docs/mcp-poll-jsonb-snapshot-acceptance-20261030.json", + "sourcePins": "tmp/mcp-poll-jsonb-fix-20261030-pins.json", + "historicalFullBaseline": "docs/mcp-poll-v3-full-local-acceptance-20261030.json" + }, + "complete37ProfilesSingleCurrentSnapshotRun": true, + "currentCandidate": { + "name": "Accepted core baseline + applied two proven Worker lifecycle fixes; source changed after core acceptance", + "targetedFunctionalAccepted": true, + "report": "docs/mcp-vrf-production-source-applied-20261101.json", + "currentFullRegressionPending": true, + "currentPrimaryAccepted": false, + "currentColdAccepted": false, + "currentLinuxAccepted": false, + "fullInstallation2GiBAccepted": false, + "sourcePins": "tmp/mcp-current-monitor-primary-retry-20261101-pins.json", + "publicProtocol": 4, + "actualMutationDebitAccepted": true, + "windowsSingleColdAccepted": true, + "currentColdAcceptanceScope": "Earlier frozen cold/1879 and1881/generic acceptance remain historical. Current leaf/alias sources have build0 and focused acceptance; full38/primary pending.", + "currentColdStabilityReport": "docs/mcp-poll-v4-windows-cold-stability-20261031.json", + "priorFrozenWindowsColdStabilityAccepted": true, + "currentNaturalExpiryV4ArchiveAccepted": false, + "currentColdFunctionalAccepted": false, + "currentNaturalExpiryV4ArchiveReport": "docs/mcp-v4-combined-archive-stage-20261031.json", + "currentFull37Accepted": false, + "currentFull37Report": "docs/mcp-owner-envelope-integration-acceptance-20261101.json", + "sourceFileCount": 1896, + "currentScopedRestoreAccepted": true, + "currentAppliedOwnerEnvelopeAccepted": true, + "currentNetFlowRegisteredReadAccepted": false, + "historicalAfterGenericOwnerApply": true, + "currentNetFlowRouteAccepted": false, + "currentGenericRouteAccepted": false, + "unusedDbPrimitiveAddedAfterGenericRouteAcceptance": true, + "currentFull38OnProductSourcesAccepted": false, + "currentFull38Report": "docs/mcp-current38-monitor-regression-acceptance-20261101.json", + "solePostMatrixDelta": "backend/src/modules/mcp/integration.test.ts", + "currentPrimaryReport": "docs/mcp-current-core-primary-restore-acceptance-20261101.json", + "priorCoreReport": "docs/mcp-current-core-primary-restore-acceptance-20261101.json", + "productDeltaAfterCore": [ + "backend/src/services/traffic-flow-worker-ipc.owned.test.ts", + "backend/src/services/traffic-flow-collector-worker.ts", + "backend/src/services/traffic-flow-worker-shutdown.ts" + ], + "targetedPostCoreAcceptance": "docs/mcp-flow-worker-startup-faults-source-applied-20261101.json", + "stdioSourceAppliedReport": "docs/mcp-stdio-current-catalogue-source-applied-20261101.json", + "publicActiveSourceAppliedReport": "docs/mcp-flow-public-active-source-applied-20261101.json", + "routingCommonAdmissionSourceReport": "docs/mcp-routing-common-admission-source-applied-20261101.json" + }, + "historicalBaseline37ProfileMatrix": { + "status": "terminal", + "accepted": true, + "profiles": 37, + "failed": [], + "statusFile": "tmp/mcp-policy-regression-v3-durable-20261030-status.json", + "log": "tmp/mcp-policy-regression-v3-durable-20261030.log", + "report": "docs/mcp-policy-regression-acceptance-20261030.json", + "sourcePinsFile": "tmp/mcp-policy-regression-v3-durable-20261030-pins.json", + "historicalBaselineOnly": true + }, + "currentPrimaryRun": { + "status": "accepted1896-before-worker-fixes", + "accepted": true, + "report": "docs/mcp-current-core-primary-restore-acceptance-20261101.json", + "processSession": 15607, + "sourceFileCount": 1896, + "realRestore": true + }, + "latestInstallationBudgetPreflight": { + "report": "docs/mcp-installation-configured-budget-20261030.json", + "tests": 10, + "lintExitCode": 0, + "ioPerformed": false, + "runtimeAcceptanceClaimed": false, + "configuredLimitInventoryReadOnly": true, + "allFourConfiguredCapsZero": true + }, + "latestPostV16ReadonlyMemory": { + "file": "tmp/mcp-test-server-memory-after-v16-20261030.json", + "availableKiB": 918608, + "swapOutDeltaDuring5Seconds": 0, + "configuredDockerLimitsRead": false, + "existingContainers": 4 + }, + "currentCollectorSerializationCandidate": { + "status": "v3-functional-coload-frozen37-and-primary-accepted-runtime-gates-open", + "affectedServices": [ + "uptime resources", + "uptime ping probes", + "traffic", + "internet path" + ], + "knownCoLoadFailure": "runtime uptime timestamps changed during poll; excluded by shared collector/exclusive application protocol", + "guardsRelaxed": false, + "migrationsChanged": false, + "scope": "Four complete collector paths hold shared advisory application lock; mutation holds exclusive lock; verified operation retains original private client protocol", + "report": "docs/mcp-collector-coload-acceptance-20261030.json" + }, + "collectorCoLoad": { + "backendOnlyAccepted": true, + "sharedFourCollectorPaths": true, + "staleApprovalSafetyAccepted": true, + "delayedApprovalUsabilityAccepted": true, + "wholeInstallation2GbAccepted": false, + "report": "docs/mcp-poll-delayed-approval-acceptance-20261030.json", + "currentFrozenPrimaryAnd37Accepted": true + }, + "currentPrimaryRepeat": { + "status": "pending-after-v4-private-wiring", + "accepted": false, + "historicalLastReport": "docs/mcp-poll-v3-full-local-acceptance-20261030.json" + }, + "checkpointObserverFixture": { + "acceptedNarrow": true, + "unitCases": 6, + "productionSourcesChanged": false, + "guardsRelaxed": false, + "report": "docs/mcp-checkpoint-observer-acceptance-20261030.json", + "fullPrimaryScopedSectionAccepted": true, + "fullPrimaryStillRunning": false, + "log": "tmp/mcp-collector-checkpoint-final-20261030-primary-restore.log", + "fullPrimaryAndRestoreAccepted": true + }, + "currentProductionBuild": { + "exitCode": 0, + "buildLog": "tmp/mcp-poll-v4-durable-validation-20261030-build.log", + "sourcePins": "tmp/mcp-poll-v4-durable-validation-20261030-pins.json" + }, + "currentRestPollRoute": { + "status": "v4-targeted-rest-mcp-accepted-combined-repeat-pending", + "accepted": true, + "report": "docs/mcp-poll-v4-public-cold-acceptance-20261031.json", + "verifiedHttpOrigin": true, + "legacyFallback": false, + "original180sBudgetRetained": true, + "batchSerial": true, + "validation": "tmp/mcp-poll-rest-route-validation-20261030.json", + "jsonbSnapshotAccepted": true, + "jsonbReport": "docs/mcp-poll-jsonb-snapshot-acceptance-20261030.json", + "publicProtocol": 4 + }, + "delayedApprovalFoundation": { + "status": "v3-delayed-approval-functional-and-real-collector-coload-accepted-cold-open", + "report": "docs/mcp-poll-delayed-approval-acceptance-20261030.json", + "actualSqlGroups": 32, + "componentTests": 5, + "migrationRegistered": true, + "runtimeWired": true, + "usabilityAccepted": true, + "fullExecutionProofUnchanged": true, + "original41MigrationsByteIdentical": true, + "actualMigrationRestoreAccepted": true, + "actualSqlGuardGroups": 32, + "migrationUpgradeRestoreGroups": 5, + "authoritativeCatalogBound": true, + "immutableStartStored": true, + "currentRealCollectorCoLoadAccepted": true, + "archiveCurrentAccepted": true, + "terminalSourceV3ComponentsAccepted": true, + "terminalSourceV3ActualArchiveAccepted": true, + "terminalFixReport": "docs/mcp-poll-v3-terminal-and-diagnostic-progress-20261030.json", + "archiveReport": "docs/mcp-poll-v3-archive-history-acceptance-20261030.json", + "fullLocalVerificationReport": "docs/mcp-poll-v3-full-local-acceptance-20261030.json", + "privateAtomic20sWaitAccepted": true + }, + "previousPolicyLoanCandidate": { + "name": "Original metadata transaction policy loan", + "accepted": true, + "productSourcesChangedSinceRegression": true, + "originalGrantPreAndPostKept": true, + "noDecisionCache": true, + "currentFullRegressionPending": false, + "fullInstallation2GiBAccepted": false, + "currentColdAccepted": false, + "coldFailureMs": 180766, + "coldFailureLastStage": "publish.141", + "coldFailureReceiptRows": 0, + "coldFailureLog": "tmp/mcp-poll-policy-loan-final-20261030-cold.log", + "exactLinuxPackage": "tmp/mcp-poll-linux-container-policy-loan-v16-20261030.tgz", + "linuxPackageTransferred": true, + "scope": "Local same-owner POLL policy loan only", + "report": "docs/mcp-poll-policy-loan-acceptance-20261030.json", + "currentPrimaryAccepted": true, + "currentPrimaryReport": "docs/mcp-poll-policy-loan-primary-acceptance-20261030.json", + "performanceAccepted": false, + "linuxNormalFunctionalAccepted": true, + "linuxOperatingAccepted": false, + "linuxReport": "docs/mcp-poll-policy-loan-linux-20261030.json", + "exactV16SingleRunConsumed": true + }, + "currentVerificationRun": { + "status": "terminal", + "startedAt": "2026-10-30T12:07:27.738Z", + "wrapperPid": 34600, + "childPid": null, + "steps": [ + { + "name": "owned-durable", + "log": "tmp/mcp-poll-v4-durable-validation-20261030-owned-durable.log", + "startedAt": "2026-10-30T12:07:28.715Z", + "finishedAt": "2026-10-30T12:08:36.045Z", + "code": 0, + "signal": null + }, + { + "name": "build", + "log": "tmp/mcp-poll-v4-durable-validation-20261030-build.log", + "startedAt": "2026-10-30T12:08:37.425Z", + "finishedAt": "2026-10-30T12:09:17.476Z", + "code": 0, + "signal": null + }, + { + "name": "lint", + "log": "tmp/mcp-poll-v4-durable-validation-20261030-lint.log", + "startedAt": "2026-10-30T12:09:19.299Z", + "finishedAt": "2026-10-30T12:09:23.892Z", + "code": 0, + "signal": null + } + ], + "exitCode": 0, + "sourcePins": "tmp/mcp-poll-v4-durable-validation-20261030-pins.json", + "step": "lint", + "sourcePinsUnchanged": true, + "finishedAt": "2026-10-30T12:09:25.377Z", + "statusFile": "tmp/mcp-poll-v4-durable-validation-20261030-status.json", + "unifiedExecSessionId": 58288 + }, + "previousVerificationRun": { + "status": "terminal", + "startedAt": "2026-10-30T08:36:20.438Z", + "wrapperPid": 47412, + "childPid": null, + "steps": [ + { + "name": "archive-history-restore", + "log": "tmp/mcp-poll-v3-final-v4-20261030-archive-history-restore.log", + "startedAt": "2026-10-30T08:36:20.939Z", + "finishedAt": "2026-10-30T08:52:37.397Z", + "code": 1, + "signal": null + } + ], + "exitCode": 1, + "sourcePins": "tmp/mcp-poll-v3-final-v4-20261030-pins.json", + "step": "archive-history-restore", + "error": "Failed archive-history-restore\n\n1 !== 0\n", + "sourcePinsUnchanged": true, + "finishedAt": "2026-10-30T08:52:38.478Z" + }, + "atomicMaintenanceCandidate": { + "status": "private-durable-accepted-public-integration-pending", + "report": "docs/mcp-poll-v4-private-durable-acceptance-20261030.json", + "components": 8, + "engineModelScenarios": 12, + "actualLogicalOperations": 143, + "actualGroups": 18, + "actualJournalRows": 50, + "fullLogicalCost": 151, + "fullReservedRows": 4019, + "fullReservedBytes": 8421376, + "productionGroupedEngineImplemented": true, + "durableJournal": true, + "actualPrivateCryptoHistory": true, + "authenticWrongGroupMacRefusals": 3, + "publicRegistry": true, + "performanceAccepted": false, + "publicCold143Accepted": false, + "actualMcpMutationDebitAccepted": true, + "terminalSourceComponentTests": 50, + "windowsSingleCold143Accepted": true, + "publicReport": "docs/mcp-poll-v4-public-cold-acceptance-20261031.json" + }, + "latestJsonbSnapshotReport": "docs/mcp-poll-jsonb-snapshot-acceptance-20261030.json", + "latestPrivateAtomicReport": "docs/mcp-poll-v4-private-durable-acceptance-20261030.json", + "latestPublicV4Report": "docs/mcp-poll-v4-public-cold-acceptance-20261031.json", + "remainingPackagesByDirection": { + "POLL": 5, + "DB": 5, + "ROUTING": 5, + "NATIVE": 5, + "PATHS": 4, + "EXTERNAL-UI": 4, + "FINAL": 3 + }, + "remainingPackageEstimate": { + "total": 32, + "approximate": true, + "note": "Historical approximate32 mixing working decomposition and canonical parents; superseded for current reporting by progressAccounting. Do not present as exact remaining tasks.", + "supersededForCurrentReporting": true + }, + "shortQueue": { + "total": 4, + "complete": 4, + "remaining": 0, + "items": [ + { + "id": "POLL-Windows-cold-stability", + "status": "accepted-on-pinned-snapshot", + "report": "docs/mcp-poll-v4-windows-cold-stability-20261031.json" + }, + { + "id": "DB-1-signature-current-head-component", + "status": "component-accepted", + "report": "docs/mcp-db-source-binding-acceptance-20261031.json", + "readyAuthorized": false + }, + { + "id": "EXT-2.B1", + "status": "component-accepted", + "report": "docs/mcp-generic-result-budget-acceptance-20261031.json", + "productionTransportExecuted": false + }, + { + "id": "N5a-deploy-lifetime-wiring", + "status": "static-and-shell-fixture-accepted", + "report": "docs/mcp-native-template-lifetime-deploy-wiring-20261031.json", + "dockerExecuted": false + } + ] + }, + "parallelWork": { + "requestedModel": "GPT-6.1 Sol", + "reasoningEffort": "medium", + "activePreparedBlocks": [ + "DB: typed paged fixed-catalogue collector and production cost/default topology bridge", + "ROUTING: single-forward durable charge + common admission/completion/retention (migration43 staged only)", + "UI2: actual Next/backend/producer/browser artifact download and delegated-permission revocation" + ] + }, + "latestColdStabilityReport": "docs/mcp-poll-v4-windows-cold-stability-20261031.json", + "previousShortQueue": { + "total": 3, + "complete": 3, + "remaining": 0, + "items": [ + { + "id": "DB-source-capture-regression", + "status": "accepted", + "sqlGroups": 17, + "report": "docs/mcp-db-source-capture-adopted-acceptance-20261031.json" + }, + { + "id": "RT3.B1", + "status": "component-accepted", + "tests": 15, + "report": "docs/mcp-routing-remaining-blocks-20261031.md" + }, + { + "id": "N5a-template-lifetime", + "status": "local-accepted", + "report": "docs/mcp-native-template-lifetime-acceptance-20261031.json" + } + ] + }, + "currentCombinedAcceptance": { + "status": "failed", + "statusPath": "tmp/mcp-v4-combined-20261031-status.json", + "sourcePins": "tmp/mcp-v4-combined-20261031-pins.json", + "steps": [ + "archive-cold-history-restore", + "regression37", + "primary-restore" + ], + "completed": 2, + "total": 3, + "currentStep": "primary-restore", + "archiveStageReport": "docs/mcp-v4-combined-archive-stage-20261031.json", + "regressionStageReport": "docs/mcp-v4-combined-regression-stage-20261031.json", + "primaryRefusalReport": "docs/mcp-v4-combined-primary-refusal-20261101.json", + "terminalStatusPath": "tmp/mcp-v4-combined-20261031-status.json", + "sourcePinsUnchanged": true + }, + "capacity336Profile": { + "status": "terminal-refused", + "profileVersion": 5, + "session": 80249, + "runId": "run-capacity336-2026-11-01T04-16-54-584Z", + "observedAt": "2026-11-01T08:24:49.257Z", + "operationsProduced": 106, + "archivesAuthenticated": 99, + "nativeGets": 424, + "workerErrors": 1, + "phase": "failed", + "wrapperPid": 28408, + "childPid": null, + "postgresPid": 41576, + "rawStatus": "tmp/mcp-poll-capacity336-v5-20261101/run-capacity336-2026-11-01T04-16-54-584Z/status.json", + "rawLifecycle": "tmp/mcp-poll-capacity336-v5-20261101/run-capacity336-2026-11-01T04-16-54-584Z/lifecycle.json", + "naturalSmokeAccepted": true, + "smokeReport": "docs/mcp-poll-capacity336-v5-smoke-acceptance-20261101.json", + "capacity336Accepted": false, + "wholeInstallation2GbAccepted": false, + "fourteenDaysElapsedAccepted": false, + "sourceGraph": "independent frozen Windows build with42 migrations", + "exitCode": 1, + "report": "docs/mcp-poll-capacity336-v5-refusal-20261101.json", + "cleanupComplete": true, + "failureStage": "harvest-query", + "failureSourceIndex": 100, + "causeEstablished": false + }, + "actualMcpDiscovery": { + "status": "measurement-accepted-on-own-pg", + "registeredActions": 363, + "discoveredTools": 363, + "resultBytes": 374771, + "jsonRpcBytes": 374805, + "sseBytes": 374828, + "report": "docs/mcp-generic-wire-measurement-acceptance-20261031.json", + "productionAuthPluginExecuted": false, + "endpointBoundAccepted": false, + "requestId128Integrated": false + }, + "genericMcpResultBudget": { + "status": "integrated-component-accepted", + "focusedTests": 27, + "sdkCases": 14, + "report": "docs/mcp-generic-result-budget-acceptance-20261031.json", + "fullEndpointBoundAccepted": false + }, + "lastUpdatedAt": "2026-11-02T12:44:31.234236Z", + "latestAttemptReport": "docs/mcp-v4-combined-primary-refusal-20261101.json", + "dbWriterBarrierComponent": { + "accepted": true, + "report": "docs/mcp-db-writer-barrier-owned-acceptance-20261101.json", + "actualSqlGroups": 11, + "productionIntegrated": false, + "allWritersAccepted": false, + "generationAccepted": false, + "ready": false, + "cleanupConfirmed": true + }, + "currentSmallQueue": { + "total": 4, + "complete": 4, + "remaining": 0, + "items": [ + { + "id": "DB-writer-barrier-component", + "status": "accepted", + "report": "docs/mcp-db-writer-barrier-owned-acceptance-20261101.json" + }, + { + "id": "FINAL-grant-fixture", + "status": "applied-targeted-http-accepted", + "actualHttpCases": 3, + "fullPrimaryAccepted": false, + "report": "docs/mcp-grant-expiry-fixture-acceptance-20261101.json" + }, + { + "id": "EXT-single-message-admission", + "status": "integrated-focused-production-sql-http-accepted", + "actualSqlHttpGroups": 6, + "fullEndpointAccepted": false, + "report": "docs/mcp-wire-admission-acceptance-20261101.json" + }, + { + "id": "ROUTING-historical-reader", + "status": "integrated-semantic-lint-owned-sql-accepted", + "actualSqlGroups": 16, + "newHourlyGroups": 6, + "securityProof": false, + "allWritersAccepted": false, + "report": "docs/mcp-routing-ledger-reader-acceptance-20261101.json" + } + ], + "note": "Child components only; broad direction estimates unchanged until their required gates are accepted." + }, + "currentCandidateRetry": { + "status": "complete", + "completed": 2, + "total": 2, + "currentStep": "complete", + "sessionId": 61278, + "statusPath": "tmp/mcp-final-candidate-20261101-status.json", + "sourcePins": "tmp/mcp-final-candidate-20261101-pins.json", + "buildExitCode": 0, + "primaryAccepted": true, + "restoreAccepted": true, + "sourceFileCount": 1879, + "regressionReport": "docs/mcp-final-candidate-regression-acceptance-20261101.json", + "sourceFreeze": false, + "primaryReport": "docs/mcp-final-candidate-primary-restore-acceptance-20261101.json", + "snapshotScope": "exact1879-file candidate before later owner/envelope patches" + }, + "priorCandidateComponentSnapshot": { + "name": "Atomic-v4 plus DB source-binding and generic MCP result delivery; current combined acceptance running", + "targetedFunctionalAccepted": true, + "report": "docs/mcp-poll-v4-public-cold-acceptance-20261031.json", + "currentFullRegressionPending": false, + "currentPrimaryAccepted": false, + "currentColdAccepted": false, + "currentLinuxAccepted": false, + "fullInstallation2GiBAccepted": false, + "sourcePins": "tmp/mcp-poll-v4-durable-validation-20261030-pins.json", + "publicProtocol": 4, + "actualMutationDebitAccepted": true, + "windowsSingleColdAccepted": true, + "currentColdAcceptanceScope": "Three prior pinned Windows repetitions accepted; later combined source is under current archive-cold/full37/restore verification", + "currentColdStabilityReport": "docs/mcp-poll-v4-windows-cold-stability-20261031.json", + "priorFrozenWindowsColdStabilityAccepted": true, + "currentNaturalExpiryV4ArchiveAccepted": true, + "currentColdFunctionalAccepted": true, + "currentNaturalExpiryV4ArchiveReport": "docs/mcp-v4-combined-archive-stage-20261031.json", + "currentFull37Accepted": true, + "currentFull37Report": "docs/mcp-v4-combined-regression-stage-20261031.json", + "historicalSnapshotOnly": true + }, + "priorCapacity336V3Snapshot": { + "status": "v3-refused-cleaned", + "report": "docs/mcp-poll-capacity336-v3-refusal-20261101.json", + "capacity336ActualAccepted": false, + "ownPostgresStopped": true, + "ownedDirectoryRemoved": true, + "next": "V4 UTC capture and stage diagnostics; smoke1 must pass before full336", + "priorAttemptReport": "docs/mcp-poll-capacity336-first-attempt-20261031.json", + "profileVersion": 3, + "lifecyclePath": "tmp/mcp-poll-capacity336-v3-20261101/run-2026-10-31T17-22-00-823Z/lifecycle.json", + "statusPath": "tmp/mcp-poll-capacity336-v3-20261101/run-2026-10-31T17-22-00-823Z/status.json", + "sourcesPath": "tmp/mcp-poll-capacity336-v3-20261101/run-2026-10-31T17-22-00-823Z/sources.json", + "frozenManifest": "tmp/mcp-poll-capacity336-v3-20261101/prepared.json", + "operationsProduced": 8, + "operationsTarget": 336, + "archivesAuthenticated": 0, + "nativeGets": 32, + "workerErrors": 0, + "phase": "natural-expiry-default-worker", + "cleanupPending": false, + "cancelFile": "C:\\Users\\shats\\Dev\\MikrotikManager-3\\tmp\\mcp-poll-capacity336-v3-20261101\\run-2026-10-31T17-22-00-823Z\\cancel.json", + "runId": "run-2026-10-31T17-22-00-823Z", + "paidCompactedSources": 1, + "nextV4Smoke": { + "status": "running", + "scope": "pinned-build-natural-expiry-smoke1", + "sessionId": 64240, + "run": "tmp/mcp-poll-capacity336-v4-20261101/run-smoke1-2026-10-31T18-16-33-840Z", + "frozenManifestSha256": "966ca2624dcfe99aa6ba2ba5fd55be6e8f306a4c57d8c85693442f1710e3c2ac", + "frozenPinsPreflightAccepted": true, + "capacity336ActualAccepted": false, + "wholeInstallation2gbAccepted": false + } + }, + "dbSequenceBarrierComponent": { + "accepted": true, + "report": "docs/mcp-db-sequence-barrier-owned-acceptance-20261101.json", + "actualSqlGroups": 13, + "blockedAllocationPaths": 11, + "nativeIdentityRetained": true, + "currentFullApplicationSchemaEnrolled": false, + "productionIntegrated": false, + "allWritersAccepted": false, + "ready": false + }, + "wholeInstallationInventoryContract": { + "report": "docs/mcp-whole-2gb-inventory-contract-20261101.json", + "offlineTestsPassed": 8, + "actualDockerInventoryVerified": false, + "wholeInstallation2GbAccepted": false + }, + "nextStagedQueue": { + "total": 4, + "verified": 4, + "remaining": 0, + "items": [ + { + "id": "DB-native-identity-barrier", + "status": "staged-real-sql-accepted", + "report": "docs/mcp-db-sequence-barrier-owned-acceptance-20261101.json" + }, + { + "id": "2GB-inventory-contract", + "status": "offline-contract-accepted", + "report": "docs/mcp-whole-2gb-inventory-contract-20261101.json" + }, + { + "id": "ROUTING-same-owner-charge", + "status": "isolated-compiled-overlay-accepted-not-installed", + "report": "docs/mcp-routing-ledger-owner-admission-acceptance-20261101.json", + "actualSqlGroups": 19 + }, + { + "id": "NETFLOW-exact-envelope-allowance", + "status": "staged-semantic-unit-sdk-accepted-not-installed", + "report": "docs/mcp-netflow-envelope-staged-acceptance-20261101.json", + "unitCases": 24, + "syntheticActualSdkCases": 28, + "actualProductRouteAccepted": false + } + ], + "productionApplicationDeferredUntilCurrentPinnedRunTerminal": true + }, + "wholeInstallationInventoryCollector": { + "status": "prepared-linux-af-unix-execution-pending", + "report": "tmp/mcp-whole-2gb-review-20261101/collector-review.json", + "offlineProjectionAndCliChecksPassed": 4, + "actualAfUnixCasesSkipped": 10, + "actualDockerInventoryVerified": false + }, + "accepted1879Candidate": { + "name": "2026-11-01: applied fixed grant fixture, single-message admission and authenticated routing history; current build0", + "targetedFunctionalAccepted": true, + "report": "docs/mcp-final-candidate-primary-restore-acceptance-20261101.json", + "currentFullRegressionPending": false, + "currentPrimaryAccepted": true, + "currentColdAccepted": false, + "currentLinuxAccepted": false, + "fullInstallation2GiBAccepted": false, + "sourcePins": "tmp/mcp-final-candidate-20261101-pins.json", + "publicProtocol": 4, + "actualMutationDebitAccepted": true, + "windowsSingleColdAccepted": true, + "currentColdAcceptanceScope": "Prior frozen component evidence retained separately; current exact1879-file full37 and primary/restore running", + "currentColdStabilityReport": "docs/mcp-poll-v4-windows-cold-stability-20261031.json", + "priorFrozenWindowsColdStabilityAccepted": true, + "currentNaturalExpiryV4ArchiveAccepted": false, + "currentColdFunctionalAccepted": false, + "currentNaturalExpiryV4ArchiveReport": "docs/mcp-v4-combined-archive-stage-20261031.json", + "currentFull37Accepted": true, + "currentFull37Report": "docs/mcp-final-candidate-regression-acceptance-20261101.json", + "sourceFileCount": 1879, + "historicalAfterOwnerEnvelopeApply": true + }, + "currentOwnerEnvelopeIntegration": { + "status": "complete", + "sessionId": 50720, + "statusPath": "tmp/mcp-owner-envelope-integration-20261101-status.json", + "sourcePins": "tmp/mcp-owner-envelope-integration-20261101-pins.json", + "completed": 2, + "total": 2, + "currentStep": "complete", + "buildExitCode": 0, + "unitTestsPassed": 28, + "ownerPatchApplied": true, + "envelopePatchApplied": true, + "actualCurrentNetFlowRouteAccepted": true, + "sourceFreeze": false, + "report": "docs/mcp-owner-envelope-integration-acceptance-20261101.json", + "netFlowReport": "docs/mcp-netflow-real-route-acceptance-20261101.json", + "currentScopedRestoreAccepted": true, + "historicalAfterGenericOwnerApply": true + }, + "dbCurrent42Profile": { + "status": "staged-full-schema-role-barrier-accepted", + "report": "docs/mcp-db-current42-role-barrier-acceptance-20261101.json", + "actualSqlGroups": 8, + "baseTables": 102, + "actualPartitionLeaves": 12, + "profileTargets": 114, + "migrations": 42, + "identitySequences": 7, + "productionWiring": false, + "allWritersAccepted": false, + "ready": false + }, + "dbControlledMarkerComponent": { + "status": "accepted-staged-fixed-profile-not-installed", + "report": "docs/mcp-db-controlled-marker-profile-acceptance-20261101.json", + "actualSqlGroups": 20, + "migrations": 42, + "enrolledTables": 114, + "priorFailuresRetained": 3, + "sourcePins": 1623, + "productionIssuance": false, + "profileWiring": false, + "allWritersAccepted": false, + "ready": false + }, + "vrfProductionExecutorCore": { + "status": "staged-core-accepted-full-feature-in-development", + "report": "docs/mcp-vrf-production-core-staged-acceptance-20261101.json", + "actualSqlHttpCases": 11, + "positiveConfigurationCases": 6, + "negativeCases": 5, + "actualAuthPolicyApprovalCrypto": true, + "actualRouter": false, + "forwardingVerified": false, + "productionApplied": false, + "fullFeatureComplete": false, + "remaining": [ + "genuine physical/VLAN/inactive CREATE slices", + "full policy closure and production integration", + "actual CHR and forwarding acceptance" + ] + }, + "genericMcpInFlightOwner": { + "status": "applied-build-unit-and-actual-route-accepted", + "report": "docs/mcp-generic-inflight-current-integration-20261101.json", + "syntheticSdkGroups": 18, + "registrationScenarios": 7, + "negativeRegistrationCalls": 24, + "productionApplied": true, + "actualAuditSqlAccepted": true, + "beforeFastifyParserAccepted": false, + "wholeEndpointAccepted": false, + "heapBoundAccepted": false, + "componentCases": 22, + "buildExitCode": 0, + "terminalWireLatchApplied": true, + "terminalWireLatchReport": "docs/mcp-terminal-wire-latch-applied-20261101.json", + "priorRealRouteRefusalsPreserved": 2, + "actualRouteSqlHttpGroups": 8, + "cleanupConfirmed": true, + "sourceSnapshot": { + "files": 3703, + "sha256": "e74fe223c39538b7b0478332e59ba978b5a3305fcffbe8591e5d0a586cb3fbf5" + }, + "sourceSnapshotHistoricalAfterUnusedDbPrimitiveAddition": true, + "sourceSnapshotHistoricalAfterVrfApply": true + }, + "dbOperatorPairing": { + "status": "staged-live-pairing-accepted-not-activated", + "report": "docs/mcp-db-operator-pairing-acceptance-20261101.json", + "actualSqlGroups": 20, + "migrations": 42, + "enrolledBaselineTables": 114, + "newActualPartitionDdlExercised": true, + "productionWiring": false, + "productionIssuance": false, + "allWritersAccepted": false, + "ready": false, + "sourceAdded": true, + "sourceReport": "docs/mcp-db-operator-primitive-source-20261101.json", + "buildExitCode": 0 + }, + "vrfProductionPhysical": { + "status": "staged-component-accepted", + "report": "docs/mcp-vrf-production-physical-staged-acceptance-20261101.json", + "actualSqlHttpCases": 7, + "productionApplied": false, + "actualRouter": false, + "forwardingVerified": false + }, + "dbFlowPairingAdapter": { + "status": "staged-actual-engine-accepted", + "report": "docs/mcp-db-flow-pairing-adapter-acceptance-20261101.json", + "actualSqlGroups": 6, + "productionHostWired": false, + "allWritersAccepted": false, + "ready": false + }, + "vrfProductionExecutorCurrent": { + "status": "implementation-and-local-integration-accepted", + "report": "docs/mcp-vrf-production-source-applied-20261101.json", + "productionApplied": true, + "sourceFileCount": 1896, + "buildExitCode": 0, + "pureCases": 81, + "requiredIntegrationSessionId": 15607, + "statusPath": "tmp/mcp-current-monitor-primary-retry-20261101-status.json", + "requiredIntegrationTotal": 2, + "requiredIntegrationCompleted": 2, + "fullImplementationAccepted": true, + "actualRouter": false, + "forwardingVerified": false, + "canonicalProfileGroupsAccepted": 64, + "currentProfileGroups": 68, + "initialIntegrationExitCode": 1, + "initialRegressionPassed": 37, + "initialRegressionFailed": 1, + "subsequentAuthExitCode": 0, + "businessAliasApplied": true, + "businessAliasReport": "docs/mcp-vrf-business-alias-acceptance-20261101.json", + "currentProfileGroupsAccepted": 68, + "completionReport": "docs/mcp-current-core-primary-restore-acceptance-20261101.json" + }, + "partitionGuardInheritance": { + "productionApplied": true, + "targets": [ + { + "file": "backend/src/db/partition-guard-inheritance.ts", + "expected": "0c8b8716b660649f974431e172be6dc487941a40efce1df355f9fa6c6a8d317d", + "actual": "0c8b8716b660649f974431e172be6dc487941a40efce1df355f9fa6c6a8d317d", + "normalized": "0c8b8716b660649f974431e172be6dc487941a40efce1df355f9fa6c6a8d317d" + }, + { + "file": "backend/src/db/partitions.ts", + "expected": "57258e08ff2cd0b85007a123902f8e81998630af446a7fe66db244fbc70a37c1", + "actual": "57258e08ff2cd0b85007a123902f8e81998630af446a7fe66db244fbc70a37c1", + "normalized": "57258e08ff2cd0b85007a123902f8e81998630af446a7fe66db244fbc70a37c1" + }, + { + "file": "backend/src/db/partitions-writer.test.ts", + "expected": "23ac9a54b18900101d1ac318799092e8ab9bb363300d54600b891a22d694e951", + "actual": "23ac9a54b18900101d1ac318799092e8ab9bb363300d54600b891a22d694e951", + "normalized": "27f71db48de83cb7206b70446cf1d7b8c1bd31513c01e5b767685dfe3c6b322b" + } + ], + "buildSession": 24006, + "buildExitCode": 0, + "modelScenarios": 15, + "lintExitCode": 0, + "genuineSqlReceipt": "docs/mcp-db-partition-guard-inheritance-acceptance-20261101.json", + "productionIssuer": false, + "allWritersAccepted": false, + "ready": false + }, + "sdkTerminalHttpCurrent": { + "date": "2026-11-01", + "productionApplied": true, + "targets": [ + { + "file": "backend/src/routes/mcp-transport.ts", + "appliedSha256": "08dd757a0f1e6614de399d676bb31a95ec13e40eaf5257501284d70e75269e51", + "acceptedCandidateSha256": "56d0fddf44898189a55abe5834050ff5080d3bda9296eaba18b2bcc71a83fb95", + "newlineNormalizedIdentical": true + }, + { + "file": "backend/src/modules/mcp/flow-envelope-allowance.ts", + "appliedSha256": "af40e9fee976e8355dff7546dc42e222089272a40437eb98e7399b999fb0f24a", + "acceptedCandidateSha256": "bee4b613a90e28b96036cd388375d4ab7c7042d609043d68507a7887f06210fd", + "newlineNormalizedIdentical": true + }, + { + "file": "backend/src/modules/mcp/sdk-terminal-budget.ts", + "appliedSha256": "619cbe0b651b252ba5b7bd42b9e83d53b02a4c5b2c055775752c50c9c0581640", + "acceptedCandidateSha256": "d0afe2a0e38f8deb5260e896d96f4586db6433040a1592c25e793c07a996f839", + "newlineNormalizedIdentical": true + } + ], + "patchSha256": "18ecdba0bf7a00e5791399a850a3290e808686e1faf32dab207d19b62e0496d6", + "buildSession": 54764, + "buildExitCode": 0, + "lintExitCode": 0, + "permanentReceiptProtectionCases": 4, + "existingFlowAllowanceCases": 4, + "actualInstalledSdkHttpGroups": 5, + "actualCurrentCatalogueRouteAuthAuditGroups": 3, + "actualCatalogueTools": 363, + "actualActivationReceipt": "tmp/mcp-sdk-terminal-activation-20261101/activation-route-acceptance.json", + "currentFullIntegrationSession": 39435, + "currentFullIntegrationStatus": "tmp/mcp-current-leaf-alias-sdk-integrated-20261101-status.json", + "sourceFileCount": 1895, + "fullEndpointAccepted": false, + "stdioBudgetAccepted": false, + "heapBoundAccepted": false, + "wholeInstallation2GbAccepted": false, + "fullPlanComplete": false + }, + "currentLeafAliasSdkIntegration": { + "status": "refused", + "sessionId": 39435, + "statusPath": "tmp/mcp-current-leaf-alias-sdk-integrated-20261101-status.json", + "sourceFileCount": 1895, + "sourcePins": "tmp/mcp-current-leaf-alias-sdk-integrated-20261101-pins.json", + "required": [ + "regression38-including-vrf68", + "primary-real-restore" + ], + "complete": 0, + "total": 2, + "accepted": false, + "failedProfile": "test:mcp:module-completion", + "compatibilityFixedAndWholeProfileRechecked": true, + "vrf68Accepted": true, + "primaryRun": false + }, + "requestMonitorDisplayProjection": { + "date": "2026-11-01", + "productionApplied": true, + "buildRequiredAfterApply": false, + "actualOwnPgGroups": 3, + "actualFastifyAuthRoutes": true, + "actualWireSocket": false, + "samples": 6, + "logicalFieldCapBytes": 16384, + "compressedInputTextBytes": 780007, + "compressedInputStoredBytes": 8959, + "fixturePageBytes": 35565, + "originalJsonbUnchanged": true, + "ownerAdminForeignAndPermissionChecks": true, + "filtersAndPaginationPreserved": true, + "sourceSnapshot": { + "files": 5791, + "sha256": "fc7340fd09aff606d32e0276405c5318a028de85f60e51b97c9569cdfd7a0828" + }, + "cleanup": true, + "acceptance": "docs/mcp-monitoring-request-projection-staged-acceptance-20261101.json", + "acceptanceSha256": "a5edfff46459436c55c919210c1a5eb9889e6f835d2a9a81a14b936ad70409a1", + "preflightRefusal": "tmp/mcp-monitoring-request-projection-20261101/preflight-refusal.json", + "fullUiAccepted": false, + "wholeInstallation2GbAccepted": false, + "fullPlanComplete": false, + "semanticErrorCount": 0, + "semanticRootFiles": 920, + "semanticSourceFiles": 2247, + "buildExitCode": 0, + "lintExitCode": 0, + "sourceAppliedReport": "docs/mcp-monitoring-request-projection-source-applied-20261101.json" + }, + "currentMonitorIntegration": { + "sessionId": 51363, + "status": "regression-accepted-primary-refused", + "sourceFileCount": 1896, + "sourcePins": "tmp/mcp-current-monitor-integrated-20261101-pins.json", + "statusPath": "tmp/mcp-current-monitor-integrated-20261101-status.json", + "required": [ + "regression38-including-vrf68", + "primary-real-encrypted-restore" + ], + "complete": 1, + "total": 2, + "accepted": false, + "regressionReport": "docs/mcp-current38-monitor-regression-acceptance-20261101.json", + "primaryRefusal": { + "phase": "VRF alias preparation", + "reason": "test address catalog omitted its native connected route", + "durationMs": 37563, + "productionGuardLoosened": false + } + }, + "currentPrimaryFixtureRetry": { + "sessionId": 15607, + "status": "complete", + "complete": 1, + "total": 1, + "accepted": true, + "statusPath": "tmp/mcp-current-monitor-primary-retry-20261101-status.json", + "sourcePins": "tmp/mcp-current-monitor-primary-retry-20261101-pins.json", + "sourceFileCount": 1896, + "soleDeltaFromFull38": [ + "backend/src/modules/mcp/integration.test.ts" + ], + "allProductionSourcesIdentical": true, + "regressionReport": "docs/mcp-current38-monitor-regression-acceptance-20261101.json", + "processExitCode": 0, + "report": "docs/mcp-current-core-primary-restore-acceptance-20261101.json" + }, + "linuxInventoryCollector": { + "date": "2026-11-01", + "accepted": true, + "scope": "generic collector Linux AF_UNIX tests and GET-only Docker inventory; no application-source upload or operating window", + "target": "161.104.46.222", + "processSession": 54215, + "processExitCode": 0, + "archiveSha256": "f22e3ed6222c7835d475e361a32fc393fa8382296b3cbe129e6a971c7ea49f46", + "controllerSha256": "81f10b0cc169bfba16344bc54a804d233c7f6c4bfe8af0b93a794a85dc0ddce0", + "bootstrapSha256": "a3b5347e5106a130036f336e78ebae2b6de44ce65c520933d07c62b6a59fe763", + "offlineControllerChecks": 4, + "linuxAfUnixGroups": 10, + "offlineProjectionGroups": 3, + "testCensus": 14, + "platformSkippedTests": 1, + "collectorElapsedMs": 2466, + "collectorMaxRssKiB": 30872, + "hostPhysicalBytes": 2069721088, + "hostFreeBeforeBytes": 881926144, + "hostFreeAfterBytes": 861609984, + "observedContainers": 4, + "observedMemoryCaps": [ + 0, + 0, + 0, + 0 + ], + "baselineUnchanged": true, + "cleanup": true, + "serviceChanges": false, + "applicationCodeUploaded": false, + "applicationDatabaseAccess": false, + "chrTouched": false, + "acceptance": "docs/mcp-linux-inventory-collector-raw-acceptance-20261101.json", + "acceptanceSha256": "306a1dac98d1b9a09502d4b618668417fc403105af963d70940bcc76b4f96b2e", + "wholeInstallation2GbAccepted": false, + "fullPlanComplete": false + }, + "latestLinuxInventoryCollectorReport": "docs/mcp-linux-inventory-collector-acceptance-20261101.json", + "completedNativeVrfImplementationParent": { + "id": "NATIVE-production-vrf-companion-implementation", + "complete": true, + "report": "docs/mcp-current-core-primary-restore-acceptance-20261101.json", + "criteria": "full CRUD/ticket/native identity/causal conservation/business alias+canonical scope, current68/full38 and core SQL/HTTP/real restore", + "nativeForwardingSeparate": true, + "remainingNativePackages": 7 + }, + "currentProductCoreAcceptance": { + "accepted": true, + "report": "docs/mcp-current-core-primary-restore-acceptance-20261101.json", + "regressionProfiles": 38, + "vrfGroups": 68, + "primarySqlHttp": true, + "realEncryptedRestore": true, + "sourceFileCount": 1896, + "sourcePins": "tmp/mcp-current-monitor-primary-retry-20261101-pins.json", + "testFixtureOnlyDeltaFromRegression": [ + "backend/src/modules/mcp/integration.test.ts" + ], + "allProductionSourcesIdentical": true, + "fullPlanComplete": false, + "historicalAfterWorkerApply": true + }, + "completedNativeWorkerFaultParent": { + "id": "NATIVE-local-worker-startup-retirement-faults", + "complete": true, + "report": "docs/mcp-flow-worker-startup-faults-source-applied-20261101.json", + "actualGroups": 12, + "lifecycleChecks": 21, + "remainingNativePackages": 6, + "publicActiveMutationAndExporterSeparate": true + }, + "stdioCurrentBridge": { + "status": "source-applied", + "actualGroups": 2, + "componentGroups": 4, + "tools": 363, + "realRevokedHttp401": true, + "allFiveChildrenExited": true, + "cleanupConfirmed": true, + "report": "docs/mcp-stdio-current-catalogue-source-applied-20261101.json", + "externalLiveClientsAccepted": false + }, + "completedNativePublicActiveParent": { + "complete": true, + "actualGroups": 4, + "publicPositivePaths": 6, + "report": "docs/mcp-flow-public-active-source-applied-20261101.json" + }, + "currentRoutingCommonAdmission": { + "status": "source-applied", + "actualGroups": 5, + "unitTests": 48, + "report": "docs/mcp-routing-common-admission-source-applied-20261101.json", + "allWriterLedgerComplete": false + }, + "latestCurrentBuild": { + "session": 95412, + "exitCode": 0, + "includesForwardCharge43": true, + "includesRoutingCommonAdmission": true, + "includesStdioGuard": true, + "includesFlowCallerContextFix": true, + "includesControlledSeedEpochFix": true, + "includesPrivateWriterFormat": true + }, + "currentLogsLiveDataSlice": { + "status": "accepted-owned-current-backend-and-ui-merge", + "cases": 1, + "followers": 2, + "matchingFactsPerFollower": 5201, + "uiRetainedRows": 5000, + "realManualCloseAndMaintenanceResetEof": true, + "actualFreshHistoryAfterGateRelease": true, + "syntheticIssuer": true, + "mountedBrowserOrFullUi2Accepted": false, + "report": "docs/mcp-logs-live-reset-acceptance-20261101.json" + }, + "currentDbProfileIssuer": { + "status": "accepted-owned-bounded-component-five", + "actualFiveAccepted": true, + "productionActivated": false, + "priorRefusalsPreserved": 3, + "productionMap": "docs/mcp-db-profile-issuer-production-map-20261101.md", + "attemptsStarted": 4, + "currentAttemptSession": 59880, + "acceptedAttempt": 4, + "report": "docs/mcp-db-protected-profile-issuer-acceptance-20261101.json", + "rawEvidenceHashesVerifiedByRoot": true, + "allWriterReady": false, + "defaultAndHighRetentionCatalogueProductionAccepted": false + }, + "capacity336V4Refusal": { + "status": "v4-capacity-refused-terminal", + "profileVersion": 4, + "scope": "pinned-build-capacity336", + "capacity336ActualAccepted": false, + "sessionId": 70870, + "runId": "run-capacity336-2026-10-31T18-36-08-991Z", + "lifecyclePath": "tmp/mcp-poll-capacity336-v4-20261101/run-capacity336-2026-10-31T18-36-08-991Z/lifecycle.json", + "statusPath": "tmp/mcp-poll-capacity336-v4-20261101/run-capacity336-2026-10-31T18-36-08-991Z/status.json", + "sourcesPath": "tmp/mcp-poll-capacity336-v4-20261101/run-capacity336-2026-10-31T18-36-08-991Z/sources.json", + "operationsProduced": 189, + "operationsTarget": 336, + "archivesAuthenticated": 184, + "nativeGets": 759, + "workerErrors": 0, + "observedAt": "2026-11-01T02:03:38.994Z", + "cleanupPending": false, + "ownPostgresStopped": true, + "ownedDirectoryRemoved": true, + "cancelFile": "C:\\Users\\shats\\Dev\\MikrotikManager-3\\tmp\\mcp-poll-capacity336-v4-20261101\\run-capacity336-2026-10-31T18-36-08-991Z\\cancel.json", + "frozenManifest": "tmp/mcp-poll-capacity336-v4-20261101/prepared.json", + "nextV4Smoke": { + "status": "accepted", + "report": "docs/mcp-poll-capacity-v4-smoke-acceptance-20261101.json", + "processExitCode": 0, + "cleanupConfirmed": true + }, + "priorAttemptReport": "docs/mcp-poll-capacity336-v3-refusal-20261101.json", + "phase": "produce-await-terminal", + "sourceIndex": 190, + "report": "docs/mcp-poll-capacity336-v4-refusal-20261101.json", + "exactUnderlyingCauseConfirmed": false, + "futureFixtureTerminalStatusCaptureAdded": true, + "futureFixtureInertChecksExitCode": 0 + }, + "currentRoutingForwardCharge": { + "status": "source-applied-canonical43-six-accepted", + "actualGroups": 6, + "sourceApplied": true, + "report": "docs/mcp-routing-forward-charge-source-applied-20261101.json", + "canonical43Accepted": true, + "currentFullRegressionPending": true, + "nonemptyChargeDumpRestoreAccepted": true, + "restartAccepted": false, + "allWriterLedgerComplete": false, + "nonemptyRestoreReport": "docs/mcp-routing-forward-restore-partial-acceptance-20261101.json", + "restartDiagnosticSession": 50102, + "restartDiagnosticStatus": "terminal-scoped-charge-conservation-confirmed-full-sequence-equality-refused", + "chargedRowsCrashConservationAccepted": true, + "fullSnapshotRestartAccepted": false, + "restartScopedReport": "docs/mcp-routing-forward-charge-forced-restart-scoped-20261101.json" + }, + "currentMountedArtifactJourney": { + "status": "accepted-mounted-artifact-revocation-slice", + "actualBrowserJourneyAccepted": false, + "actualProducerAndOneDumpPassed": true, + "retryUnderReadOnlyInvestigation": false, + "attempt2Session": 89790, + "attempt1Report": "docs/mcp-ui2-database-artifact-mounted-attempt1-20261101.json", + "attempt2Report": "docs/mcp-ui2-database-artifact-mounted-attempt2-20261101.json", + "attempt3Session": 71252, + "actualPublicNextPrepared": true, + "ownedDirectoryRetained": true, + "retainedBasename": "owned-mounted-3tPtg0", + "currentSession": 92310, + "currentFullManifestFiles": 6412, + "currentFullManifestSha256": "dc6efe60fae5faa2da762a863022c742e13c5df2123031a532efcf6e4cde37f5", + "browserPreflightAccepted": true, + "browserPreflightReport": "docs/mcp-ui2-database-artifact-browser-preflight-20261101.json", + "attempt4Accepted": false, + "actualDownloadMatchedReceipt": true, + "storedPermission403Observed": true, + "sameJwtAttempt4Accepted": false, + "ownerBootstrapDiagnosticAccepted": true, + "ownerBootstrapReport": "docs/mcp-ui2-owner-bootstrap-egress-20261101.json", + "expectedAnonymousSwitcherRequests": 2, + "allObservedSwitcherRequestsAborted": true, + "fullUi2Accepted": false, + "attempt5Report": "docs/mcp-ui2-database-artifact-mounted-attempt5-20261101.json", + "currentCleanupAccepted": true, + "retainedBasenames": [ + "owned-mounted-3tPtg0", + "owned-mounted-j9KuPe" + ], + "buildOnlyAccepted": true, + "buildOnlyReport": "docs/mcp-ui2-build-tree-only-acceptance-20261101.json", + "attempt6Report": "docs/mcp-ui2-database-artifact-mounted-attempt6-20261101.json", + "browserAssertionsPassed": true, + "sameJwtAttempt6Accepted": true, + "nodeSocketRefusedAttempts": 2, + "nodeSocketCauseKnown": false, + "attempt7Report": "docs/mcp-ui2-database-artifact-mounted-attempt7-20261101.json", + "nodeSocketFailurePhase": "actual-producer-before-next-and-browser", + "nodeSocketCallerComponentKnown": false, + "acceptedScoped": true, + "latestReport": "docs/mcp-ui2-database-artifact-revocation-acceptance-20261101.json", + "lifecycleReport": "docs/mcp-ui2-database-artifact-mounted-lifecycle-20261101.json", + "actualDownloadedBytes": 309924, + "actualDownloadedSha256": "57c3a3554ba47c6f53b8564e198ac927a38b5c5674afd217d423acc32617b8b6", + "sameOwnerJwt": true, + "storedDelegatedPermission403": true, + "downloads": 1, + "dumpStarts": 1, + "knownOfflineGeoipDenials": 2, + "unknownNodeDenied": 0, + "expiryAccepted": false, + "unknownNativeAccepted": false, + "terminalConnectionRevocation": true, + "storedCredentialsRevoked": true, + "artifactStillUnexpiredAtRevocation": true, + "ownerManagementRead200": true, + "storedRevocation403": true, + "allCurrentResourcesCleaned": true + }, + "currentFullRegressionPending": true, + "currentPrimaryAccepted": true, + "current43Acceptance": { + "status": "accepted-current-local-composed38-and-primary-restore", + "session": 51679, + "sourceFiles": 1898, + "statusFile": "tmp/mcp-current43-two-profiles-primary-v2-20261101-status.json", + "controlledMigrationsActualCases": 8, + "referenceActualCases": 11, + "schemaControlActualCases": 17, + "schemaInventoryActualCases": 17, + "dataInventoryActualCases": 31, + "passedLanesTerminalEncryptedRestoreAndTamper": true, + "previousWrapperRefusal": { + "session": 27839, + "statusFile": "tmp/mcp-current43-seedfix-integrated-20261101-status.json", + "cause": "Orchestrator accidentally set MMAPP_PG_CLIENT_BIN for targeted epoch-only lane, enabling unrelated poll-storage restore-evidence consumer after actual32+5 accepted; runner-only PATH/flag correction, source identical." + }, + "wholeAccepted": true, + "full38PriorStatus": "tmp/mcp-current43-final-four-20261101-status.json", + "priorPassedProfiles": 36, + "priorFailedProfiles": [ + "test:auth", + "test:traffic-flow" + ], + "onlyTwoTestFixturesChanged": true, + "primaryAccepted": true, + "composed38Accepted": true, + "current38RequiresOnlyTwoTestFixtureDelta": true, + "composed38Report": "docs/mcp-current43-composed38-acceptance-20261101.json", + "report": "docs/mcp-current43-regression-restore-acceptance-20261101.json", + "sourcePinsUnchanged": true, + "sourceExtension": { + "sourceFiles": 1905, + "addedUnwiredFilesSincePrimary": 7, + "scope": "Private canonical/format/read/structure modules and registered model tests; no public runtime import. Current auth/build accepted, not a new full38 run.", + "report": "docs/mcp-writer-profile-registry-structure-source-applied-20261101.json", + "primaryReexecuted": false, + "authSession": 2418, + "buildSession": 18336 + } + }, + "current43PriorRefusal": { + "session": 69453, + "report": "docs/mcp-controlled-seed-epoch-source-applied-20261101.json", + "firstLaneActualSql8Accepted": true, + "firstLaneTerminalRestoreAccepted": true, + "referenceSeedRefused": true, + "sourceUnchanged": true + }, + "currentControlledSeedEpoch": { + "status": "source-applied-unit-build-lint-and-actual11-reference-restore-passed", + "report": "docs/mcp-controlled-seed-epoch-source-applied-20261101.json", + "unitTests": 8, + "originAttested": false, + "ready": false, + "actualSqlCases": 11 + }, + "currentDbPaged43Reference": { + "status": "actual-independent-reference-data-one-accepted", + "baseTables": 103, + "actualSqlGroups": 1, + "report": "docs/mcp-db-explicit43-reference-data-acceptance-20261101.json", + "rootHashAndComparatorVerified": true, + "rootApprovedForTestLiteralOnly": true, + "productionAuthority": false, + "costRunStarted": true + }, + "currentMigrationGitBytes": { + "status": "rule-applied43-git-filters-preserve-raw-bytes", + "report": "docs/mcp-migration-git-byte-preservation-20261101.json", + "sourceSqlBytesChanged": false, + "freshCheckoutAccepted": false + }, + "currentDbPaged43Cost": { + "status": "actual-four-accepted-staged-test-definition-only", + "groups": 4, + "report": "docs/mcp-db-paged43-cost-acceptance-20261101.json", + "rootEvidenceHashesVerified": true, + "defaultTables": 220, + "highTables": 741, + "futureTables": 742, + "defaultCatalogueMs": 389, + "highCatalogueMs": 803, + "defaultEngineBatchMs": 1169, + "highEngineBatchMs": 1937, + "highTempBytes": 14983168, + "testTsxNodeRssBytes": 464007168, + "compiledRuntimeOrWholeInstallationMemoryAccepted": false, + "postgresNativeRssMeasured": false, + "productionActivated": false, + "allWriterReady": false + }, + "currentWriterProfileFormat": { + "status": "source-applied-unit7-auth-profile-build-lint-pass", + "report": "docs/mcp-writer-profile-format-source-applied-20261101.json", + "privateModules": 2, + "registeredUnitGroups": 7, + "actualAuthProfileSession": 71333, + "issuerActivated": false, + "protectedKeyLoaderOrHeadRegistry": false + }, + "currentNativeRecursiveData": { + "status": "actual-raw-capture-only", + "report": "docs/mcp-native8-recursive-raw-capture-20261101.json", + "actualRestGets": 18, + "rows": 66, + "devices": 2, + "semanticProof": false, + "remoteWrites": 0, + "offlineValidation": { + "report": "docs/mcp-native8-recursive-offline-validation-20261101.json", + "rootVerifiedArtifacts": 28, + "actualDecodedResponses": 18, + "actualDecodedRows": 66, + "typedFactoryRefusals": 2, + "typedRouteParserInvocations": 0, + "semanticProof": false, + "scopedTests": 9, + "productApplied": false + } + }, + "progressAccounting": { + "report": "docs/mcp-evidence-statistics-20261101.json", + "humanReport": "docs/mcp-evidence-statistics-20261101.md", + "old32IsExact": false, + "combinedRemainingTaskCount": null, + "openDirections": 7, + "nativeAccepted": 3, + "nativeTotal": 8, + "nativeOpen": 5, + "dbCanonicalParents": 4, + "dbWorkingBlocks": 5, + "routeInventoryChecked": true + }, + "currentWriterProfileRegistryRead": { + "status": "private-read-component-source-applied-model-and-auth-build-lint-pass", + "report": "docs/mcp-writer-profile-registry-read-source-applied-20261101.json", + "modelUnitGroups": 6, + "authSession": 68493, + "sourceFiles": 1903, + "runtimeImported": false, + "actualReaderSqlAccepted": true, + "authority": false, + "ready": false, + "actualReadContractGroups": 3, + "actualReadContractObservations": 6, + "sqlReport": "docs/mcp-db-registry-read-contract-acceptance-20261101.json", + "sqlSession": 85081, + "rootVerifiedEvidenceFiles": 8 + }, + "dependencyAudit": { + "report": "docs/mcp-dependency-audit-20261101.json", + "humanReport": "docs/mcp-dependency-audit-20261101.md", + "runtimeDirectAdded": 4, + "devDirectAdded": 1, + "lockProductionAdded": 5, + "lockDevAdded": 10, + "wholeScopePreserved": true + }, + "currentWriterProfileRegistryStructure": { + "status": "source-applied-and-actual-sql-parity-accepted", + "report": "docs/mcp-writer-profile-registry-structure-source-applied-20261101.json", + "sourceFiles": 1905, + "modelUnitGroups": 4, + "metadataRefusals": 16, + "inputRefusals": 16, + "actualTypedStructurePgAccepted": true, + "runtimeImported": false, + "authority": false, + "ready": false, + "sqlReport": "docs/mcp-db-registry-structure-contract-acceptance-20261101.json", + "actualGroups": 1 + }, + "currentRoutingManualInverse": { + "report": "docs/mcp-routing-manual-inverse-charge-reproduction-20261101.json", + "actualGapReproduced": true, + "hourlyBeforeForward": 0, + "hourlyAfterForward": 1, + "hourlyAfterInverse": 1, + "genericAfterInverse": 3, + "nativeWrites": 2, + "productionFixApplied": false, + "migration44Applied": false, + "rt3Complete": false, + "stagedSqlSixCaseAccepted": true, + "stagedSqlFailureReport": "tmp/mcp-routing-manual-inverse-fix-20261101/runs/mm-mcp-test-vrf-WbTyGG/acceptance.json", + "latestStagedReport": "docs/mcp-routing-manual-inverse-six-case-acceptance-20261101.json", + "stagedPrefixCompleted": 6, + "stagedCasesTotal": 6, + "nonpersistentCompatibilityAccepted": true, + "productSourceApplied": true, + "nonpersistentCompatibilityScope": "No enabled persistent writer on the fixture server; live writer guard preserved", + "migration44SyntheticSqlApplied": true, + "rootVerifiedArtifacts": 12, + "migration44InSource": true, + "migration44AppliedToProductionDatabase": false, + "sourceTargets": 22, + "newSourceFiles": 4, + "currentSourceFiles": 1912, + "buildAccepted": true, + "lintAccepted": true, + "currentRegressionPending": true, + "sourceAdoptionReceipt": "tmp/mcp-routing44-source-adoption-applied-20261101.json", + "upgradeBridgeAccepted": true, + "upgradeBridgeRun": "tmp/mcp-routing44-bridge-v3-20261101/runs/mm-mcp-test-vrf-Fj5ysF", + "retention": { + "sourceApplied": true, + "intentRevision": 2, + "actualSqlAccepted": true, + "syntheticSqlAging": true, + "genuineElapsedAging": false, + "scopedNativeStubCases": 6, + "earlyOriginalCredentialRevocationRollbackAccepted": true, + "build": true, + "unitCases": 6, + "lint": true, + "report": "docs/mcp-routing-manual-inverse-intent2-acceptance-20261101.json", + "sourceReport": "docs/mcp-routing-manual-inverse-intent2-source-applied-20261101.json", + "allWriterAccepted": false, + "fullRt3Accepted": false + } + }, + "currentOwnerArtifactStatus": { + "sourceApplied": true, + "permanentRegressionAdded": true, + "build": true, + "lint": true, + "httpUnitGroups": 3, + "actualSqlPending": false, + "fullMountedRevocationPending": false, + "files": [ + "backend/src/routes/mcp.ts", + "backend/src/test/mcp-database-backup.ts" + ], + "actualSqlAccepted": true, + "authAccepted": true, + "mandatoryLogsSqlAccepted": true, + "applicationAdaptersAccepted": true, + "terminalRestoreAndTamperAccepted": true, + "report": "docs/mcp-owner-artifact-status-sql-acceptance-20261101.json", + "fullMountedRevocationAcceptedScoped": true, + "browserReport": "docs/mcp-ui2-database-artifact-revocation-acceptance-20261101.json", + "naturalExpiryAccepted": false + }, + "currentRegistryFusion": { + "sourceApplied": true, + "actualSqlGroups": 3, + "actualSqlReport": "docs/mcp-db-registry-fusion-acceptance-20261101.json", + "facadeQueriesBefore": 5, + "facadeQueriesAfter": 2, + "physicalClientStartsBefore": 25, + "physicalClientStartsAfter": 10, + "modelGroups": 6, + "parityCases": 53, + "build": true, + "lint": true, + "currentAuthPending": false, + "runtimeImported": false, + "authority": false, + "ready": false, + "wholeInstallation2GbAccepted": false, + "currentInvalidWithoutForeignActualSqlProved": false, + "currentAuthAccepted": true, + "mandatoryLogsSqlAccepted": true, + "sourcePinCount": 1908, + "sourceReport": "docs/mcp-writer-profile-registry-fusion-source-applied-20261101.json" + }, + "currentNaturalExpiry": { + "status": "terminal-functional-boundary-observed-cleanup-refused", + "session": 67119, + "area": "tmp/mcp-ui-artifact-expiry-20261101/natural-expiry-review/natural-expiry-v3", + "sourceManifestFiles": 6437, + "sourceManifestSha256": "ceef887c4d26e8dff1c0be94e0b1bd6f7270c05b9338f32570c8279b9ac782ad", + "productSourceAndDistFrozen": false, + "fixedArtifactTtlMs": 900000, + "originalOwnerAndCredentialSeconds": 1800, + "totalAcceptanceBudgetMs": 1080000, + "expiryAccepted": false, + "productionDeletionAccepted": false, + "report": "docs/mcp-ui2-natural-expiry-boundary-partial-20261101.json", + "fullFixtureCleanup": false, + "ownedDirectoryRetained": true, + "pgStopped": true, + "nativeExitConfirmed": true, + "functionalBoundaryObserved": true, + "previousBudgetRefusalReport": "docs/mcp-ui2-natural-expiry-budget-refusal-20261101.json", + "processTrackerReview": "tmp/mcp-process-tree-independent-review-20261101/review.md", + "processTrackerCandidateApplied": false, + "actualUnknownCauseConfirmed": false, + "diagnosticCandidate": { + "path": "tmp/mcp-process-tree-diagnostic-20261101/REVIEW.md", + "sourceApplied": false, + "pureModels": 9, + "actualWindowsLifecycleAccepted": false, + "deletionSemanticsChanged": false + }, + "shortDiagnosticSmoke": { + "path": "tmp/mcp-process-tree-diagnostic-smoke-v2-20261101/REVIEW.md", + "actualRun": true, + "sourceApplied": false, + "pureModels": 4, + "pendingPrimarySession": 10566, + "rootSession": 88956, + "accepted": true, + "report": "docs/mcp-process-tree-diagnostic-short-smoke-20261102.json", + "longNaturalExpiryAccepted": false + } + }, + "current44Integration": { + "status": "terminal-refused", + "session": 13984, + "statusFile": "tmp/mcp-current44-routing-integrated-20261101-status.json", + "sourceFiles": 1912, + "migrations": 44, + "fullAcceptance": false, + "exitCode": 1, + "sourcePinsUnchanged": true, + "failedProfile": "test:vrf:production", + "failure": "child-deadline", + "primaryRestoreRun": false, + "pollTerminalArchiveRun": false, + "reportedAggregateCaseCount": 0, + "actualCompletedVrfCases": null, + "successfulProfiles": 37, + "failedProfiles": 1, + "totalProfiles": 38, + "partialReport": "docs/mcp-current44-profiles-partial-20261101.json" + }, + "currentVrfDiagnostic": { + "scope": "Local actual SQL/auth/crypto and loopback native configuration verification", + "accepted": true, + "fullVrfAccepted": true, + "localCases": 68, + "totalCases": 68, + "remainingCases": 0, + "slices": 10, + "sameOwnedPg": true, + "rootSession": 68349, + "report": "docs/mcp-vrf-all68-component-acceptance-20261102.json", + "baselineBudgetMs": 240000, + "otherSliceBudgetMs": 120000, + "budgetsIncreasedInThisRepeat": false, + "sourceFiles": 1912, + "sourcePinsUnchanged": true, + "fullCleanup": true, + "actualRouter": false, + "forwardingVerified": false, + "wholeNativeAccepted": false, + "previousMatrixReport": "docs/mcp-current44-components-profiles-partial-20261101.json", + "previousDisabledDiagnostic": { + "path": "tmp/mcp-vrf-disabled-diagnostic-20261101/REVIEW.md", + "sourceApplied": false, + "actualRun": true, + "originalCases": 9, + "unchangedAssertions": 90, + "childBudgetMs": 120000, + "actualRootGuardPreflightPending": true, + "rootSession": 2171, + "accepted": true, + "report": "docs/mcp-vrf-disabled-nine-acceptance-20261102.json", + "budgetIncreased": false + }, + "baselineBudgetSourceReport": "docs/mcp-vrf-baseline-budget-source-applied-20261101.json", + "priorMatrixTimeoutRootCauseConfirmed": false + }, + "current44PrimaryFollowup": { + "status": "terminal-refused", + "session": 60629, + "statusFile": "tmp/mcp-current44-routing-primary-followup-20261101-status.json", + "sourceFiles": 1912, + "sourceUnchangedAtTerminal": true, + "primaryAccepted": false, + "encryptedRestoreRun": false, + "pollArchiveRun": false, + "report": "docs/mcp-current44-primary-revocation-refusal-20261101.json" + }, + "currentUiCore": { + "report": "docs/mcp-ui1-core-ownerflow-component-acceptance-20261101.json", + "status": "component-accepted", + "accepted": true, + "fullCleanup": true, + "actualSdkRequests": 8, + "oneTimeTokenHiddenAfterReload": true, + "pauseAndRevokeAccepted": true, + "originalOwnerJwtRemainsValid": true, + "fullUi1Accepted": false, + "fullUi2Accepted": false, + "livePortalAccepted": false + }, + "currentProtocolGetAudit": { + "protocolGetCompletedObservedBeforeFix": true, + "actualWireStatusPaired": true, + "sourceTimingDefectProved": true, + "sourceFixApplied": true, + "actualSqlAccepted": true, + "unitCases": 27, + "build": true, + "lint": true, + "report": "docs/mcp-transport-get-audit-repair-acceptance-20261101.json", + "fullUiOwnerFlowAccepted": false + }, + "currentOwnerDelegateBoundary": { + "sourceApplied": true, + "actualHttpAccepted": true, + "staged": true, + "genuineOwner401MustRemain": true, + "delegatedUnauthorizedMustBecome403": true, + "startupRefusals": [ + "docs/mcp-owner-delegate-startup-refusal-20261101.json", + "docs/mcp-owner-delegate-loader-refusal-20261101.json" + ], + "permanentRegressionStaged": "tmp/mcp-owner-delegate-route-regression-20261101/manifest.json", + "baselineReproduced401": true, + "candidatePausedAndNoBearer403": true, + "sameOwnerManagement200": true, + "invalidExpiredRevokedOwner401Preserved": true, + "build": true, + "registeredRouteRegression": true, + "lint": true, + "actualRootSession": 47810, + "actualProof": "tmp/mcp-owner-delegate-owned-parent-v4-20261101/attempt-0iWbGO/acceptance.json", + "sourceReport": "tmp/mcp-owner-delegate-source-applied-20261101.json", + "fullUiAccepted": false, + "allSevenCallsitesActualAccepted": false, + "report": "docs/mcp-owner-delegate-boundary-acceptance-20261101.json" + }, + "currentFinalRegression": { + "status": "terminal-refused", + "session": 85600, + "statusFile": "tmp/mcp-current44-components-final-20261101-status.json", + "fixedSource": true, + "profilesExpected": 38, + "primaryAndEncryptedRestorePending": true, + "pollArchivePending": true, + "accepted": false, + "exitCode": 1, + "sourceUnchanged": true, + "profilesSuccessful": 37, + "profilesFailed": 1, + "acceptedVrfCases": 27, + "vrfCasesNotAccepted": 41, + "failedVrfSlice": "disabled", + "failure": "child-deadline", + "disabledExecutedCaseCount": null, + "report": "docs/mcp-current44-components-profiles-partial-20261101.json" + }, + "wholeInstallationResourceNextSpec": { + "path": "tmp/mcp-ram1-next-acceptance-spec-20261101/SPEC.md", + "executable": false, + "priorInputsRehashed": 19, + "whole2GbAccepted": false, + "availableHostCoexistenceProved": false + }, + "currentComponentsPrimaryFollowup": { + "status": "terminal-refused", + "session": 10566, + "statusFile": "tmp/mcp-current44-components-primary-20261101-status.json", + "sameMatrixSnapshot": true, + "accepted": false, + "primaryRestorePending": true, + "pollArchivePending": true, + "originalCredentialRefusalObserved": { + "stage": "original-credential-revocation-complete", + "unauthorized401": true, + "fullAdmissionRollback": true, + "nativeReads": 0, + "nativeWrites": 0, + "wholeSuiteAccepted": false, + "log": "tmp/mcp-current44-components-primary-20261101-primary-restore.log", + "primaryTerminalAcceptance": false + }, + "exitCode": 1, + "sourceUnchangedAtTerminal": true, + "report": "docs/mcp-current44-hostfiles-expectation-refusal-20261102.json" + }, + "currentTypedCatalogueValues": { + "path": "tmp/mcp-db-catalogue-value-prerequisite-20261101/REVIEW.md", + "sourceApplied": false, + "pureCompiledTests": 20, + "actualSql": false, + "totalConstraintCap": 2048, + "defaultWarmupAccepted": false, + "authority": false, + "ready": false + }, + "currentNative8NextBridge": { + "path": "tmp/mcp-native8-next-bridge-review-20261101/REVIEW.md", + "actualOfflineResponses": 18, + "actualOfflineRows": 66, + "sourceEnvelopeRefusals": 2, + "issuesPerSource": 15, + "missingFieldsPerSource": 14, + "unexpectedMetadataKeysPerSource": 11, + "typedParserInvocations": 0, + "semanticVerdict": "unknown", + "sourceGenerationIssued": false, + "authority": false + }, + "currentHostFilesExpectation": { + "testOnlyCorrectionApplied": true, + "productRouteChanged": false, + "expectedPausedOwnerStatus": 403, + "sameJwtManagement200Asserted": true, + "missingJwt401Asserted": true, + "focusedSqlAccepted": true, + "report": "docs/mcp-host-files-owner-boundary-acceptance-20261102.json", + "rootSession": 48440, + "ownerAfterPause403": true, + "sameOwnerJwtManagement200": true + }, + "currentPrimaryRestoreArchive": { + "accepted": true, + "report": "docs/mcp-current44-primary-restore-archive-acceptance-20261102.json", + "rootSession": 63050, + "migrations": 44, + "sourceFiles": 1912, + "sourceUnchanged": true, + "primaryGroups": 137, + "realEncryptedRestoreAccepted": true, + "naturalTerminalArchiveAccepted": true, + "all38ProfilesSingleRunAccepted": false + }, + "currentMillionLogBoundary": { + "accepted": true, + "report": "docs/mcp-logs-million-boundary-acceptance-20261102.json", + "rootSession": 51681, + "seedRows": 1000000, + "finalRows": 1000203, + "mountedUiAccepted": false, + "activeCollectorAccepted": false + }, + "currentCatalogue44DataCost": { + "acceptedMeasurement": true, + "report": "docs/mcp-catalogue44-data-cost-acceptance-20261102.json", + "rootSession": 2235, + "baseTables": 104, + "partitionLeaves": 117, + "constraints": 2228, + "actualSqlStarts": 14, + "fetchCalls": 6, + "policyVerdict": "REFUSED", + "runtimeAuthorityAccepted": false, + "ready": false + }, + "latestEvidenceReferenceVerification": { + "reports": [ + { + "name": "primary", + "path": "docs/mcp-current44-primary-restore-archive-acceptance-20261102.json", + "verifiedReferences": 16 + }, + { + "name": "host", + "path": "docs/mcp-host-files-owner-boundary-acceptance-20261102.json", + "verifiedReferences": 28 + }, + { + "name": "logs", + "path": "docs/mcp-logs-million-boundary-acceptance-20261102.json", + "verifiedReferences": 34 + }, + { + "name": "catalogue", + "path": "docs/mcp-catalogue44-data-cost-acceptance-20261102.json", + "verifiedReferences": 90 + } + ], + "totalReferences": 168 + }, + "currentHumanPlanUi": { + "accepted": true, + "report": "docs/mcp-ui-human-plan-component-acceptance-20261102.json", + "rootSession": 69916, + "cases": 1, + "guiPlans": 2, + "operations": 1, + "nativeWrites": 1, + "unapprovedHttp409": true, + "pausedPlanInvalidated": true, + "pausedApplyButtonAbsent": true, + "pausedDirectHttp403": true, + "sameOwnerJwtManagement200": true, + "cleanupFullyAccepted": true, + "wholeUiAccepted": false + }, + "currentCapacityV6Preparation": { + "strictTypecheckAccepted": true, + "rootSession": 51373, + "exitCode": 0, + "command": "node tmp/mcp-poll-capacity336-v6-20261102/prepare.mjs --check", + "sourceGate": { + "path": "tmp/mcp-poll-capacity336-v6-20261102/root-current44-source-pins.json", + "sha256": "77b9d20d1e0bd152fbd16bf1e419d1e188594f88004cc0f87f377b47caf9f384" + }, + "sourceFiles": 1912, + "contractsBuildFiles": 96, + "emitted": false, + "postgresStarted": false, + "capacityRunStarted": false, + "capacity336Accepted": false, + "persistentStage": "tmp/mcp-poll-capacity336-v6-host-20261102", + "persistentPrepared": { + "frozen": "C:\\Users\\shats\\AppData\\Local\\Temp\\mm-capacity336-frozen-v6-1793586523185", + "manifestSha256": "65d3fe3511c30874e024ed2814214a105c01962f68b3796dc32dce93eba650fa", + "scope": "pinned-build-capacity336", + "postgresStarted": false + }, + "freezeRootSession": 29232, + "freezeExitCode": 0, + "frozenPreflightRootSession": 21589, + "frozenPreflightExitCode": 0, + "frozenRuntimeFiles": 15794, + "compiledSourceFiles": 713, + "priorLocationRefusal": "docs/mcp-capacity336-v6-freeze-location-refusal-20261102.json", + "smoke1RootSession": 3288, + "smoke1Status": "terminal-accepted", + "smoke1Report": "docs/mcp-poll-capacity336-v6-smoke-acceptance-20261102.json", + "smoke1ExitCode": 0, + "smoke1Operations": 2, + "smoke1Archives": 1, + "smoke1NativeGets": 8, + "smoke1WorkerErrors": 0 + }, + "currentQueue": { + "basis": "Five concrete current blocks, not whole-plan denominator", + "closed": 4, + "remaining": 1, + "items": [ + { + "id": "human-plan-ui", + "status": "accepted", + "report": "docs/mcp-ui-human-plan-component-acceptance-20261102.json" + }, + { + "id": "poll-capacity", + "status": "full-v7-and-v8-terminal-refused-state-unavailable-investigation-required" + }, + { + "id": "catalogue44", + "status": "data-candidate-five-sql-cases-accepted-production-issuer-open", + "area": "tmp/mcp-db-catalogue44-budget-owned-host-v6-20261102", + "report": "docs/mcp-catalogue44-budget-acceptance-20261102.json" + }, + { + "id": "artifact-natural-expiry", + "status": "accepted-real-expiry-and-complete-job-cleanup", + "area": "tmp/mcp-ui-owned-job-runtime-hash-v2-20261102", + "session": 22132, + "accepted": true, + "report": "docs/mcp-ui-artifact-expiry-job-acceptance-20261102.json", + "cleanupAccepted": true, + "run": "own-job-ias9obi3" + }, + { + "id": "routing-transport-charge", + "status": "accepted-source-applied", + "area": "tmp/mcp-routing-forward-transport-20261102", + "report": "docs/mcp-routing-forward-transport-acceptance-20261102.json" + } + ] + }, + "currentCatalogue44Sql": { + "accepted": true, + "report": "docs/mcp-catalogue44-clean-sql-acceptance-20261102.json", + "rootSession": 5623, + "groups": 1, + "baseTables": 104, + "baseConstraints": 1130, + "scans": 2, + "fetches": 8, + "cleanupFullyAccepted": true, + "sourceUnchanged": true, + "defaultTopologyAccepted": false, + "derived3714PolicyApproved": false, + "productionAuthority": false + }, + "currentRoutingForwardTransportSql": { + "accepted": true, + "report": "docs/mcp-routing-forward-transport-acceptance-20261102.json", + "positiveCasesAccepted": 2, + "negativeCasesAccepted": 4, + "admissionRegressionAccepted": true, + "sqlCases": 7, + "cleanupAccepted": true, + "sourceUnchangedDuringSql": true, + "productApplied": true, + "postApplyModelTests": 58, + "buildPassed": true, + "lintPassed": true, + "fullRt3Accepted": false + }, + "currentCatalogue44BudgetSql": { + "accepted": true, + "report": "docs/mcp-catalogue44-budget-acceptance-20261102.json", + "rootSession": 71810, + "exitCode": 0, + "actualSqlCases": 5, + "positiveCases": 2, + "negativeCases": 3, + "defaultLeaves": 117, + "maxLeaves": 152, + "maxConstraints": 3714, + "cleanupAccepted": true, + "productionPolicyInstalled": false, + "productionAuthority": false + }, + "currentNaturalExpiryDiagnostic": { + "accepted": false, + "report": "docs/mcp-ui2-natural-expiry-diagnostic-partial-20261102.json", + "session": 58515, + "exitCode": 1, + "functionalExpiry404Observed": true, + "cleanupAccepted": false, + "retainedDirectory": "tmp/mcp-ui2-natural-expiry-diagnostic-v2-20261102/owned-mounted-kcu8bi" + }, + "currentCapacityV7Preparation": { + "preparationAccepted": true, + "report": "docs/mcp-capacity336-v7-preparation-20261102.json", + "frozen": "C:\\Users\\shats\\AppData\\Local\\Temp\\mm-capacity336-frozen-v7-1793597833049", + "sourcePins": 713, + "runtimePins": 15794, + "full336Started": true, + "full336Accepted": false, + "preflightExitCode": 0 + }, + "currentCapacityV7Run": { + "started": true, + "accepted": false, + "status": "terminal-failed", + "session": 86432, + "statusFile": "tmp/mcp-poll-capacity336-v7-host-20261102/run-capacity336-2026-11-02T05-49-34-991Z/status.json", + "lifecycleFile": "tmp/mcp-poll-capacity336-v7-host-20261102/run-capacity336-2026-11-02T05-49-34-991Z/lifecycle.json", + "observedAt": "2026-11-02T10:50:51.806Z", + "phase": "natural-expiry-default-worker", + "operationsProduced": 133, + "archivesAuthenticated": 127, + "targetArchives": 336, + "nativeGets": 535, + "workerErrors": 0, + "originalHardDeadlineAt": "2026-11-05T05:49:42.390Z", + "command": "/node.exe --no-global-search-paths /run-owned.mjs --execute-reviewed-capacity336", + "productionOrChrTouched": false, + "wholeInstallation2GbAccepted": false, + "uiCleanupAccepted": false, + "profileVersion": 7, + "exitCode": 1, + "failedSourceIndex": 134, + "elapsedMs": 18069416, + "operationState": { + "state": "unknown", + "verification": "inconclusive", + "errorCode": "STATE_UNAVAILABLE" + }, + "rootCauseEstablished": false, + "ambientPoolAndStageNotFailingQueryProof": true, + "cleanupComplete": true, + "ownDirectoryAbsenceVerified": true, + "evidence": [ + { + "path": "tmp/mcp-poll-capacity336-v7-host-20261102/run-capacity336-2026-11-02T05-49-34-991Z/status.json", + "sha256": "cccea79de997b77bf9e6fe3b8faf1e318afba5d05c7eb1bce4ae4b69bbfe5410" + }, + { + "path": "tmp/mcp-poll-capacity336-v7-host-20261102/run-capacity336-2026-11-02T05-49-34-991Z/lifecycle.json", + "sha256": "5069e2f723257eedae66c31f296a6b7236ac94d529286a787a1a520c36bf4e76" + }, + { + "path": "tmp/mcp-poll-capacity336-v7-host-20261102/run-capacity336-2026-11-02T05-49-34-991Z/failure-observation.json", + "sha256": "151802341e5d5df8acb08e54c7d89272fcac2ff3fdab7fbf18c35bedaff2d2ce" + }, + { + "path": "tmp/mcp-poll-capacity336-v7-host-20261102/run-capacity336-2026-11-02T05-49-34-991Z/sources.json", + "sha256": "b4ff6851b4f495fb771ad7abff949d4cb835ab4d034ed537f8d00a1ce9c57d50" + } + ] + }, + "currentRamMeasurementImplementation": { + "implemented": true, + "report": "docs/mcp-ram1-measurement-implementation-20261102.json", + "sourceFiles": 7, + "semanticTests": 56, + "producerSchemas": [ + 2, + 3 + ], + "wholeInstallation2GbAccepted": false, + "actualLinuxAccepted": false + }, + "currentOwnedJobPrimitive": { + "accepted": false, + "report": "docs/mcp-owned-job-os-image-refusal-20261102.json", + "actualOsAttempted": true, + "assignmentBeforeResumeObserved": true, + "imageGuardRefused": true, + "cleanupAccepted": false, + "retainedDirectory": "tmp/mcp-owned-job-primitive-v2-20261102/runs/own-job-41vskxnp" + }, + "currentOwnedJobDiagnostic": { + "accepted": false, + "report": "docs/mcp-owned-job-v3-console-diagnostic-20261102.json", + "actualMember": "conhost.exe", + "nodeImageGuardPreserved": true, + "oldDirectoryRetained": true + }, + "currentOwnedJobOsAcceptance": { + "accepted": true, + "report": "docs/mcp-owned-job-v4-os-acceptance-20261102.json", + "rootSession": 18092, + "exitCode": 0, + "modelTests": 41, + "rootFirstAndChildAliveRefusalObserved": true, + "twoExitsZeroAndFreshOwnCleanupAccepted": true, + "browserCompatibilityAccepted": false, + "fullUi2Accepted": false + }, + "currentOwnedControlPipeOsAcceptance": { + "accepted": true, + "report": "docs/mcp-owned-control-pipe-os-acceptance-20261102.json", + "rootSession": 43881, + "exitCode": 0, + "modelTests": 70, + "requestBytes": 71, + "responseBytes": 70, + "responseFd": 3, + "ownCleanupAccepted": true, + "edgeCompatibilityAccepted": false, + "fullUi2Accepted": false + }, + "ownedEdgeJobOsComponent": { + "accepted": true, + "report": "docs/mcp-owned-edge-job-os-acceptance-20261102.json", + "pureTests": 30, + "nativeRootExit": 0, + "jobActive": 0, + "totalProcesses": 10, + "newOwnDirectoryRemoved": true, + "oldFailureRetained": true, + "fullUiAccepted": false, + "wholeInstallation2GbAccepted": false, + "independentReview": { + "path": "tmp/mcp-owned-edge-v2-independent-review-20261102/REVIEW.md", + "sha256": "f60b59b2f4b7054e3fa8039e9c7b949942fe792ce278013e8f014e15b670db67", + "blockers": 0, + "pureModels": 30, + "modelProvenanceSupplement": "docs/mcp-owned-edge-v2-model-provenance-20261102.json" + } + }, + "currentRamNoSwapCompose": { + "sourceApplied": true, + "report": "docs/mcp-ram1-no-swap-compose-source-applied-20261102.json", + "deployed": false, + "tests": 24, + "wholeInstallation2GbAccepted": false + }, + "currentOwnedUiAssembly": { + "implemented": true, + "pureTests": 30, + "report": "docs/mcp-ui-owned-job-assembly-preparation-20261102.json", + "runtimeStarted": false, + "fullUiAccepted": false, + "scheduling": "after exclusive POLL window and frozen-pin recheck", + "currentSourcePinsValid": false, + "refreshReason": "Poll expiry source/helper/package and backend build changed after its frozen source snapshot; preserve old stage and prepare new sibling before runtime." + }, + "currentVrfDurableDiagnostics": { + "sourceApplied": true, + "report": "docs/mcp-vrf-durable-diagnostics-source-applied-20261102.json", + "pureTests": 14, + "lintExitCode": 0, + "priorDeadlineFixedClaimed": false + }, + "currentDurable38Run": { + "status": "terminal-accepted", + "accepted": true, + "session": 64824, + "report": "docs/mcp-current44-durable38-acceptance-20261102.json", + "sourceFiles": 2015, + "profiles": 38, + "vrfCases": 68, + "snapshotBeforePollExpiryAlignment": true, + "coversCurrentSource": false + }, + "currentPollExpiryAlignment": { + "sourceApplied": true, + "validationReport": "docs/mcp-server-poll-expiry-alignment-validation-20261102.json", + "buildExitCode": 0, + "lintExitCode": 0, + "permanentTests": 15, + "authSqlExitCode": 0, + "actualNaturalSqlPending": false, + "newV8SourceGateSha256": "8e03208e5a9883429f39e8661f9783cbfb13cb31b6131d161ae6ca23babe6055", + "actualNegativeSqlCasesPending": true + }, + "currentCapacityV8Preparation": { + "date": "2026-11-02", + "prepared": true, + "accepted": false, + "scope": "immutable V8 default Poll expiry-alignment smoke snapshot; actual SQL/natural proof pending", + "frozen": "C:\\Users\\shats\\AppData\\Local\\Temp\\mm-capacity336-frozen-v8-1793616077751", + "frozenManifestSha256": "81c540e11b39a532ce2cb9d386401b5b8b667781105567893eae7473d61d2a50", + "rootSourceGateSha256": "8e03208e5a9883429f39e8661f9783cbfb13cb31b6131d161ae6ca23babe6055", + "sourceModules": 714, + "runtimePins": 15795, + "projectGraphFiles": 1921, + "contractsBuildFiles": 96, + "finalCheckAndHostFreezeSession": 50195, + "finalCheckAndHostFreezeExitCode": 0, + "separatePreflightSession": 49525, + "separatePreflightExitCode": 0, + "smokeSession": 87311, + "smokeStarted": true, + "smokeAccepted": true, + "full336Admitted": false, + "planExpiryMode": "original-sealed-binding", + "originalBindingCapMs": 300000, + "archiveSafetyMs": 60000, + "operationBudgetChanged": false, + "clockOrTestGrantShortened": false, + "historicalV7Session": 86432, + "historicalV7Modified": false, + "transientPreparationRefusal": "docs/mcp-capacity-v8-transient-preparation-refusal-20261102.json", + "timingAmendment": "tmp/mcp-poll-capacity336-v8-timing-amendment-20261102/REVIEW.md", + "fullUiAccepted": false, + "wholeInstallation2GbAccepted": false + }, + "currentCapacityV8Smoke": { + "accepted": true, + "report": "docs/mcp-poll-v8-natural-expiry-smoke-acceptance-20261102.json", + "session": 87311, + "elapsedMs": 433212, + "full336Accepted": false + }, + "currentCapacityV8Run": { + "profileVersion": 8, + "session": 69050, + "status": "terminal-failed", + "accepted": false, + "exitCode": 1, + "operationsProduced": 11, + "archivesAuthenticated": 5, + "nativeGets": 48, + "workerErrors": 0, + "failedSourceIndex": 12, + "observedAt": "2026-11-02T11:20:47.448Z", + "elapsedMs": 495014, + "operationState": { + "state": "unknown", + "verification": "inconclusive", + "errorCode": "STATE_UNAVAILABLE" + }, + "rootCauseEstablished": false, + "ambientPoolAndStageNotFailingQueryProof": true, + "cleanupComplete": true, + "ownDirectoryAbsenceVerified": true, + "evidence": [ + { + "path": "tmp/mcp-poll-capacity336-v8-host-20261102/run-capacity336-2026-11-02T11-12-24-603Z/status.json", + "sha256": "167fed5ea66bba19c467cdb99bc349a0aeef587b28d8a5fcd4ef0f5705b5d89f" + }, + { + "path": "tmp/mcp-poll-capacity336-v8-host-20261102/run-capacity336-2026-11-02T11-12-24-603Z/lifecycle.json", + "sha256": "94255bfed87cb5ec75acb3bcae82713b8ac23ad23c01a88282bfcc319ca81577" + }, + { + "path": "tmp/mcp-poll-capacity336-v8-host-20261102/run-capacity336-2026-11-02T11-12-24-603Z/failure-observation.json", + "sha256": "e294ceaffd85e9bb4f3aeccf1ae7c31da9cf269a1f52cbaac8fbcdcbc69b7d7e" + }, + { + "path": "tmp/mcp-poll-capacity336-v8-host-20261102/run-capacity336-2026-11-02T11-12-24-603Z/sources.json", + "sha256": "c2ea34315959be40f95d9f939e950641b4c829f0a8bb76614b8a7a7b3f929eff" + } + ] + }, + "currentCapacityTerminalRefusals": "docs/mcp-capacity-v7-v8-terminal-refusals-20261102.json", + "currentPollExpiryNegativeSql": { + "accepted": true, + "cases": 5, + "session": 94781, + "exitCode": 0, + "nativeIo": 0, + "report": "docs/mcp-poll-expiry-negative-sql-acceptance-20261102.json" + }, + "currentOwnedUiRuntime": { + "accepted": true, + "status": "terminal-artifact-expiry-and-job-cleanup-accepted", + "session": 22132, + "exitCode": 0, + "run": "own-job-ias9obi3", + "report": "docs/mcp-ui-artifact-expiry-job-acceptance-20261102.json", + "cleanupAccepted": true, + "sourceUnchanged": true + }, + "currentExpandedCatalogueSql": { + "accepted": false, + "componentsTypedValidated": [ + 117, + 638 + ], + "boundary3000Measured": false, + "session": 58232, + "exitCode": 1, + "failureCode": "SUITE_DEADLINE", + "cleanupReceipt": true, + "report": "docs/mcp-catalogue44-expanded-sql-partial-20261102.json" + }, + "previousOwnedUiRuntime": { + "accepted": false, + "status": "terminal-before-producer-insufficient-budget", + "session": 24297, + "exitCode": 1, + "report": "docs/mcp-current-ui-owned-job-budget-refusal-20261102.json", + "sourceUnchanged": true, + "cleanupAccepted": false, + "ownRootRetained": true + }, + "updatedAt": "2026-11-02T12:26:13.959903Z", + "currentArtifactExpiryJobAcceptance": { + "accepted": true, + "status": "terminal-artifact-expiry-and-job-cleanup-accepted", + "session": 22132, + "exitCode": 0, + "run": "own-job-ias9obi3", + "report": "docs/mcp-ui-artifact-expiry-job-acceptance-20261102.json", + "cleanupAccepted": true, + "sourceUnchanged": true + }, + "currentBoundary3000Diagnostic": { + "accepted": false, + "session": 97143, + "exitCode": 1, + "failureCode": "SUITE_DEADLINE", + "sourceSizeWitnessProduced": false, + "typedReaderStarted": false, + "cleanupReceipt": true, + "report": "docs/mcp-catalogue44-boundary-timeout-20261102.json" + }, + "currentPollDiagnosticOne": { + "diagnosticComplete": true, + "productionAcceptance": false, + "session": 8035, + "exitCode": 0, + "report": "docs/mcp-poll-diagnostic-one-result-20261102.json", + "rootCauseReproduced": false + }, + "currentPollDiagnostic12": { + "status": "terminal-diagnostic-complete-not-full-capacity", + "session": 53131, + "exitCode": 0, + "diagnosticComplete": true, + "productionAcceptance": false, + "operationsProduced": 12, + "archivesAuthenticated": 11, + "nativeGets": 48, + "workerErrors": 0, + "rootCauseReproduced": false, + "sameFirst12SchedulingAsFullV8": false, + "report": "docs/mcp-poll-diagnostic12-result-20261102.json" + }, + "currentBoundary3000Timing": { + "accepted": false, + "session": 35103, + "exitCode": 1, + "failureCode": "SUITE_DEADLINE", + "topologyMs": 98994, + "leafEnrollmentMs": 6659, + "retainedCaseEnteredAtMs": 118855, + "sourceWitnessProduced": false, + "cleanupReceipt": true, + "report": "docs/mcp-catalogue44-boundary-timing-result-20261102.json" + }, + "currentRollingPrefixOne": { + "diagnosticComplete": true, + "session": 84207, + "exitCode": 0, + "report": "docs/mcp-poll-rolling-prefix-one-result-20261102.json" + }, + "currentManualInverseTransport": { + "sourceApplied": true, + "actualSqlHttpCases": 11, + "positiveAndFourFaultsAccepted": true, + "originalSixRegressionAccepted": true, + "buildAfterAdoptionPending": false, + "fullRt3Accepted": false, + "report": "docs/mcp-routing-manual-transport-source-applied-20261102.json" + }, + "currentPollRollingPrefix": { + "diagnosticComplete": true, + "session": 71882, + "exitCode": 0, + "operationsProduced": 12, + "archivesAuthenticated": 5, + "nativeGets": 48, + "workerErrors": 0, + "rootCauseReproduced": false, + "full336Accepted": false, + "report": "docs/mcp-poll-rolling-prefix-result-20261102.json" + }, + "currentFixedProfileGroups": { + "sourceApplied": true, + "coverage": [ + 37, + 1 + ], + "vrfAssertions": 68, + "actualRunPending": false, + "speedupMeasured": false, + "report": "docs/mcp-fixed-profile-groups-source-applied-20261102.json", + "acceptanceBasis": "composed38; initial base37 exit1, repaired Poll profile exit0 and wholeVRF exit0" + }, + "currentPollErrorProvenance": { + "sourceApplied": true, + "privateStderrOnly": true, + "actualHelperTests": 6, + "buildPending": false, + "historicalRootCauseKnown": false, + "report": "docs/mcp-poll-error-provenance-source-applied-20261102.json" + }, + "currentCombinedValidation": { + "date": "2026-11-02", + "buildSession": 5866, + "buildExitCode": 0, + "lintSession": 92772, + "lintExitCode": 0, + "pollHelperTestsPassed": 6, + "finalMatrixStatus": "accepted-composed38-with-two-test-loader-repairs", + "base37Session": 97980, + "vrf1Session": 99097, + "sourceCount": 6103, + "sourcePinsFile": "tmp/mcp-final-matrix-repaired-source-20261102.json", + "sourcePinsSha256": "a804da515cf97baecb27d2b1c37aa3649031bc7c5f84a01e2724de22309356f2", + "completeMatrixAccepted": true, + "wholeInstallation2GbAccepted": false, + "fullPlanComplete": false, + "singleFullMatrixInvocationPassed": false, + "initialBase37ExitCode": 1, + "repairedPollSession": 20802, + "repairedPollExitCode": 0, + "vrfExitCode": 0, + "coverageProfiles": 38, + "vrfCases": 68, + "report": "docs/mcp-composed-final-regression-20261102.json", + "scope": "application runtime regression before addition of standalone offline operator CLI; CLI tested separately" + }, + "currentComposedFinalRegression": { + "accepted": true, + "coverageProfiles": 38, + "vrfCases": 68, + "singleFullInvocationPassed": false, + "originalBase37ExitCode": 1, + "targetedRepairedExitCode": 0, + "applicationRuntimeUnchanged": true, + "testOnlyRepairs": 2, + "report": "docs/mcp-composed-final-regression-20261102.json" + }, + "currentOfflineOperatorBootstrap": { + "date": "2026-11-02", + "scope": "explicit-offline-operator-bootstrap-only", + "sourceApplied": true, + "targets": [ + { + "path": "backend/scripts/database-writer-bootstrap-operator.mjs", + "sha256": "35e0b94c3662fcbcded417eb37f50293ad131abb83cf9bff7c9d28e3eacde6a7" + }, + { + "path": "backend/scripts/database-writer-bootstrap-operator.test.mjs", + "sha256": "e35a7502be9f5588c6428a8f8bd1673537f9b7e31df71f6a19fae7ddca8b74d5" + } + ], + "syntaxExitCode": 0, + "testsPassed": 4, + "actualEd25519Crypto": true, + "actualWindowsRefusalBeforeKeyFiles": true, + "actualPosixProtectedFilesystemAccepted": true, + "localLinuxAvailable": false, + "databaseObserved": false, + "rolesAuthenticated": false, + "publicationAuthorized": false, + "generationAuthenticated": false, + "readyAuthorized": false, + "runtimeImportsChanged": false, + "databaseOrNetworkOperations": false, + "fullDbDirectionAccepted": false, + "limitations": [ + "Isolated actual bootstrap main accepted; generation, all-writer operating lifecycle and READY remain open." + ], + "independentReviewReport": "tmp/mcp-db-offline-bootstrap-independent-review-20261102/REVIEW.md", + "independentReviewBlockingFindings": 0, + "posixAcceptanceReport": "docs/mcp-publisher-linux-current44-functional-acceptance-20261103.json" + }, + "currentNormalV10Capacity336": { + "status": "terminal", + "observedAt": "2026-11-02T16:47:03.619Z", + "operationsProduced": 68, + "archivesAuthenticated": 62, + "nativeGets": 275, + "workerErrors": 0, + "capacity336ActualAccepted": false, + "wholeInstallation2GbAcceptance": false, + "cleanupComplete": true, + "session": 70488, + "source": "frozen-original-production-code-no-diagnostic-overlay", + "statusFile": "tmp/mcp-poll-capacity336-normal-v10-host-20261102/run-capacity336-2026-11-02T15-45-25-670Z/status.json", + "report": "docs/mcp-capacity336-normal-v10-refusal-20261103.json", + "noRemoteOrChrChanges": true, + "exitCode": 1, + "historicalRootCauseEstablished": false + }, + "currentTestStandReadOnlyMemory": { + "report": "docs/mcp-test-stand-memory-readonly-20261102.json", + "session": 26213, + "exitCode": 0, + "hostMemTotalKiB": 2021212, + "hostMemAvailableKiB": 888976, + "hostSwapUsedKiB": 665676, + "wholeInstallation2GbAccepted": false + }, + "historicalUnknownDisposition": { + "report": "docs/mcp-historical-unknown-disposition-20261102.json", + "originalRunsRemainUnaccepted": true, + "functionalCompletionCountChanged": false + }, + "currentConnectedOperatorPublisher": { + "sourceApplied": true, + "newSourceFiles": 16, + "actualAdoptedTargetTestsPassed": 4, + "actualSqlAccepted": true, + "actualTlsAccepted": true, + "actualPosixFilesAccepted": true, + "runtimeActivated": false, + "readyAuthorized": false, + "report": "docs/mcp-db-connected-publisher-source-applied-20261102.json", + "journalLeaseSourceApplied": true, + "journalLeaseReport": "docs/mcp-db-publisher-journal-lease-source-applied-20261102.json", + "functionalAcceptanceReport": "docs/mcp-publisher-linux-current44-functional-acceptance-20261103.json", + "wholeInstallation2GbAccepted": false, + "functionalAcceptanceSourceBoundary": "Linux result precedes subsequent reconcile-only CLI admission change; publication SQL/helper unchanged. New admission has 7 local permanent tests, not a new Linux acceptance." + }, + "latestSourceImplementationReport": "docs/mcp-publisher-preflight-source-applied-20261103.json", + "currentPortalRevocationSender": { + "sourceApplied": true, + "portalFiles": 8, + "mmFiles": 2, + "lockedTypechecksPassed": 3, + "lockedDiagnostics": 0, + "pureEventTestsPassed": 3, + "principalSourceComponentTestsPassed": 22, + "enabled": false, + "actualSqlOrTlsAccepted": true, + "report": "docs/mcp-portal-sender-source-applied-20261102.json", + "genuineAcceptanceReport": "docs/mcp-portal-sender-genuine-acceptance-20261103.json", + "liveExternalAcceptance": false + }, + "currentNativeRouteOrigin": { + "sourceApplied": true, + "actualAppliedPureTestsPassed": 87, + "buildExitCode": 0, + "lintExitCode": 0, + "fullNativeAccepted": false, + "newIntentActionRegistered": false, + "report": "docs/mcp-native-route-origin-source-applied-20261103.json" + }, + "currentSourceValidation": { + "report": "docs/mcp-current-source-validation-20261103.json", + "buildExitCode": 0, + "focusedLintPassed": true, + "full38Rerun": false + }, + "currentPollProbeGuardProvenance": { + "sourceApplied": true, + "actualUnitTestsPassed": 42, + "buildExitCode": 0, + "lintExitCode": 0, + "historicalCauseEstablished": false, + "newFullRunStarted": false, + "report": "docs/mcp-poll-probe-guard-source-applied-20261103.json", + "actualPostgresCasesPassed": 4, + "actualPostgresAccepted": true, + "actualPostgresReport": "docs/mcp-poll-probe-guard-own-pg-acceptance-20261103.json" + }, + "currentPublisherLinuxV2": { + "status": "terminal-refusal", + "exitCode": 1, + "actualPublicationAccepted": false, + "actualPosixLeaseTestsPassed": 3, + "resourcesAndOuterRootRemoved": true, + "baselineMatched": true, + "causeEstablished": false, + "report": "docs/mcp-publisher-linux-v2-refusal-20261103.json" + }, + "currentPublisherLinuxV3": { + "status": "terminal-refusal", + "exitCode": 1, + "actualPublicationAccepted": false, + "diagnosticPersisted": true, + "stage": "publisher-preflight", + "exactFailingConditionEstablished": true, + "resourcesAndOuterRootRemoved": true, + "baselineMatched": true, + "authorizedRunsConsumed": 2, + "authorizedRunsRemaining": 1, + "report": "docs/mcp-publisher-linux-v3-refusal-20261103.json", + "cause": "Raw pg.Client third-argument contract mismatch in test preflight; repaired using existing production facade" + }, + "currentPortalPermissionCatalogue": { + "sourceApplied": true, + "keys": 6, + "portalFiles": 4, + "report": "docs/mcp-portal-permission-catalog-source-applied-20261103.json", + "deployed": false, + "visualAcceptance": false, + "actualUiBuildAccepted": true, + "buildReport": "docs/mcp-portal-permission-catalog-build-20261103.json", + "buildStepsPassed": 3, + "liveSourcePinsUnchanged": 336 + }, + "currentPublisherLinuxFunctional": { + "functionalAccepted": true, + "aggregateAccepted": false, + "wholeInstallation2GbAccepted": false, + "report": "docs/mcp-publisher-current-operator-linux-acceptance-20261103.json", + "workingServicesChanged": false, + "chrChanged": false, + "currentPreflightAccepted": true, + "unknownCommitSyntheticDeliveryAccepted": true + }, + "currentOperatorReconciliationAdmission": { + "sourceApplied": true, + "permanentTestsPassed": 7, + "sourceReport": "docs/mcp-publisher-reconcile-admission-source-applied-20261103.json", + "actualLostCommitSqlAccepted": true, + "newSourceLinuxAccepted": true, + "publicationChecksPreserved": true, + "productionActivated": false, + "faultScope": "Synthetic application ACK delivery loss and before-dispatch socket destruction; genuine SQL, not real packet-loss claim" + }, + "currentPollStorageLockDiagnostic": { + "sourceApplied": true, + "actualOwnedPgContentionAccepted": true, + "waitMs": 5039, + "historicalV10CauseEstablished": false, + "full336Accepted": false, + "report": "docs/mcp-poll-storage-lock-diagnostic-source-applied-20261103.json" + }, + "currentOperatorProfilePreflight": { + "sourceApplied": true, + "componentTestsPassed": 18, + "newAction": "preflight", + "writerAuthorized": false, + "generationAuthenticated": false, + "readyAuthorized": false, + "actualSqlTlsPosixAccepted": true, + "report": "docs/mcp-publisher-preflight-source-applied-20261103.json" + }, + "currentExternalInspectorBearer": { + "accepted": true, + "groups": 4, + "processes": 5, + "fullEXT2": false, + "report": "docs/mcp-inspector-http-bearer-acceptance-20261103.json" + }, + "currentExternalClientsContract": { + "canonicalEXT2Complete": true, + "report": "docs/mcp-external-clients-completion-20261103.json", + "remainingSiblingGroups": 4, + "wholeDirectionComplete": false + }, + "currentOperatorBuildArtifact": { + "sourceApplied": true, + "modules": 17, + "compiledHelpers": 9, + "runtimeDependencies": [ + "dotenv", + "pg", + "zod" + ], + "backendBuildSession": 45624, + "backendBuildExitCode": 0, + "node22TestsPassed": 2, + "sourceReport": "docs/mcp-operator-build-ci-source-applied-20261103.json", + "dockerImagesBuilt": false + }, + "previewPublication": { + "authorized": true, + "published": true, + "mmCommit": "bf3d50e1ff2ee3b5c9268991a255c3b49ce6378e", + "portalCommit": "ef7bb79d4ea4ba6b9a15d2d3edd1734756f939fa", + "branch": "codex/mcp-acceptance-clean-20261103", + "cleanLiteralScanPassed": true, + "scannedFiles": 4146, + "productionDeployAuthorized": false, + "ciDispatched": true, + "runs": { + "mm": 2585, + "portal": 2586 + }, + "report": "docs/mcp-preview-publication-20261103.json", + "verifiedImageComponents": [ + "backend", + "frontend", + "portal" + ], + "portalBuildComplete": true, + "allBuildsSucceeded": true, + "portalRunAttempt": 2, + "portalJobId": 8927, + "userManuallyRestartedPortal": true, + "agentInitiatedRetries": 0 + }, + "fourServiceRecoveryComponent": { + "stagedOnly": true, + "offlineTestsPassed": 7, + "remoteExecuted": false, + "report": "docs/mcp-four-service-recovery-component-20261103.json", + "parentIntegrationStaged": true, + "parentRootReviewAccepted": true, + "parentReport": "docs/mcp-four-service-parent-review-20261103.json", + "parentEntrypointDisabled": true, + "realLinuxRecoveryAccepted": false + }, + "wholeInstallationPreparation": { + "linuxImagesVerified": 3, + "postgresMetadataVerified": true, + "composeCandidatePrepared": true, + "composeCandidateReport": "docs/mcp-whole-installation-compose-candidate-20261103.json", + "extraTlsContainer": false, + "actualWorkerPreloadNoopAccepted": true, + "hostMemoryReport": "docs/mcp-whole-installation-host-memory-20261103.json", + "osReserveRuntimeAccepted": false, + "operatingWindowAuthorized": false, + "wholeInstallation2GbAccepted": false, + "latestPreparationReport": "docs/mcp-whole-installation-package-preparation-20261103.json", + "measuredPlanningProfile": "docs/mcp-whole-installation-measured-profile-candidate-20261103.json", + "syntheticInputsCreatedLocally": true, + "caPrivateKeyPersisted": false, + "allImagesReadableWithoutCredentials": true, + "controllerTransportMockChecks": 5, + "fullProfileRunnable": false, + "successorImplementsPublicMcpPollAndScopedMutation": true, + "windowsUiDriverAndPortalBootstrapStaged": true, + "mainWorkerTelemetryPreloadStaged": true, + "coordinatorTransportChecksLatest": 8, + "completeArchivePrepared": false, + "applicationCapsMiB": { + "backend": 512, + "postgres": 320, + "frontend": 160, + "portal": 256 + }, + "planningOsReserveMiB": 672 + }, + "deploymentTopology": { + "managerHostServices": [ + "frontend", + "backend-with-MCP", + "postgres" + ], + "portal": "separate-server", + "resourceScopeReport": "docs/mcp-separated-portal-topology-20261103.json", + "runtimeAccepted": false + }, + "latestNativeRouteOriginReport": "docs/mcp-native-route-origin-sql-acceptance-20261103.json", + "latestRoutingMixedAdmissionReport": "docs/mcp-routing-mixed-admission-20261103.json", + "latestProtectedSourceRegistryReport": "docs/mcp-protected-source-registry-component-20261103.json", + "operatingAcceptance": { + "preparationAttemptsUsed": 3, + "actualManagerStopWindowsUsed": 1, + "complete": false, + "standConfiguredStateAndHealthRestored": true, + "legacyDynamicNetworkHashExact": false, + "ownResourcesCleaned": true, + "report": "docs/mcp-manager-operating-attempts-20261103.json" + }, + "latestNetworkIntentCaptureReport": "docs/mcp-network-intent-fresh-capture-20261103.json", + "latestOperatingFollowupReport": "docs/mcp-operating-recovery-followup-20261103.json", + "latestOperatingRunReport": "docs/mcp-operating-next-run-20261103.json", + "latestUiNativeTempReport": "docs/mcp-ui-parent-temp-native-acceptance-20261103.json", + "latestProtectedRegistryPreflightReport": "docs/mcp-protected-registry-preflight-source-applied-20261103.json", + "latestOperatingRun": { + "date": "2026-11-03", + "status": "refused-at-portal-network-before-login", + "archiveSha256": "512529d18bfe107d0ee6b9b2c76ee4afbdef7f251d97ae2602308a3da7af7b12", + "instanceId": "a25e132bafc6afd39803384e", + "sourceReport": "tmp/mcp-operating-final-package-20261103/private-approved-run-1793688868905853500/driver-safe-report.json", + "sourceReportSha256": "e517788f9eebc27414b1f0fd1a9c3e231a98eeea4aa7fe1abb1b92ca332e4651", + "preparationAttemptsConsumed": 1, + "managerStopWindowsConsumed": 1, + "authorizationExhausted": true, + "allFourOwnServicesRunning": true, + "observedOom": false, + "nativeEdgeLaunchedAndRetired": true, + "uiFailedPhase": "real-portal-login", + "sshForwardConnectionRefusals": 2, + "diagnosis": "Host 127.0.0.1:55175 refused SSH forwarded connections before credential validation. Internal Portal bootstrap passed. Actual host port bindings and endpoint HTTP were not captured; internal-network port publishing is a hypothesis, not a proven server cause.", + "restoration": { + "ledgerFormat": "mmapp-restoration-baseline-v2", + "legacyBaselineExactClaimed": false, + "originalManagerServicesRestored": true, + "originalPortalPreserved": true, + "allOriginalServicesRestored": true, + "services": [ + { + "service": "portal", + "restored": true, + "preservedWithoutMutation": true + }, + { + "service": "postgres", + "restored": true, + "intendedNetworkPreserved": true, + "runtimeNetworkObservationsChanged": true, + "legacyNetworkConfigurationMatches": false + }, + { + "service": "backend", + "restored": true, + "intendedNetworkPreserved": true, + "runtimeNetworkObservationsChanged": true, + "legacyNetworkConfigurationMatches": false + }, + { + "service": "frontend", + "restored": true, + "intendedNetworkPreserved": true, + "runtimeNetworkObservationsChanged": true, + "legacyNetworkConfigurationMatches": false + } + ], + "recreatedContainers": 0, + "deletedContainers": 0 + }, + "remotePayloadRemoved": true, + "ownDockerResourcesRemoved": true, + "privateDiagnosticsRetained": true, + "localBrowserJobEmptyAndHandlesReleased": true, + "localUiFailureDirectoryRetained": false, + "localUiCleanupAccepted": true, + "smokeAccepted": false, + "fullAccepted": false, + "wholeInstallation2GbAccepted": false, + "nextStep": "Add bounded published-binding/host-HTTP/forwarded-HTTP readiness diagnostics before browser admission; review a new package before requesting another operating window.", + "localUiCleanupReport": "tmp/mcp-operating-final-package-20261103/private-approved-run-1793688868905853500/final-ui-cleanup-evidence/cleanup-result.json", + "localUiCleanupReportSha256": "5649fa512e101e8926bb1bda80ce419cf84838401003914416a85a7cd918373a" + }, + "databaseMaintenanceStartup": { + "sourceApplied": true, + "report": "docs/mcp-maintenance-startup-source-applied-20261103.json", + "profilePassed": true, + "buildPassed": true, + "publishedImageUpdated": false, + "fullDbDirectionComplete": false + }, + "operatingTransportCandidate": { + "date": "2026-11-03", + "status": "local-transport-candidate-reviewed-not-adopted", + "sourceManifest": "tmp/mcp-own-web-readiness-diagnostic-20261103/source-pins.json", + "sourceManifestSha256": "1b7f4c1ca7ad77f09ccaba83c1faa8392aaa15dd0d39f18894fafb48e949be87", + "review": "tmp/mcp-own-web-readiness-diagnostic-20261103/REVIEW.md", + "focusedMockedTestsPassed": 15, + "actualCandidateHandlerWithModeledTransportAckRaceCovered": true, + "behavior": "Keep browser loopback origins; SSH targets exact owned container addresses validated against captured IDs, images, caps and owned bridge/IPAM membership. Disable backend connections and await bounded ACK before planned restart, revalidate same ID/network before recovery.", + "readinessDiagnostics": "Bounded own-port binding/host GET/forwarded GET before browser; no credentials, redirects, response headers or body published.", + "rootCauseOfFailedHostPortProven": false, + "networkIsolationChanged": false, + "oldFrozenSourcesOrPinsChanged": false, + "adoptedIntoComposedPacket": false, + "archiveAssembled": false, + "serverOrBrowserRunPerformed": false, + "authorizationExtended": false, + "nextStep": "Adopt the reviewed helpers and actual call sites into a new fully pinned component graph; verify composed integration and prepare a concrete package before seeking a new operating window." + }, + "routedOperatingPackage": { + "date": "2026-11-03", + "status": "new-routed-private-package-and-driver-reviewed-awaiting-current-image-refresh", + "instanceId": "d08e15a8f9588403e7aade0e", + "archive": "tmp/mcp-operating-routed-package-20261103/private-archive-reviewed-v1/mm-mcp-install-window-d08e15a8f9588403e7aade0e.tar.gz", + "archiveSha256": "d0c7746360a12cd9b321ef03308318265847cf6e114cfee5aa686205143e11f1", + "archiveBytes": 74302, + "fileCount": 29, + "tarMembers": 31, + "driver": "tmp/mcp-operating-routed-package-20261103/run-routed-reviewed.py", + "driverSha256": "f6f03bd4aecd17c2ac3bbea307ef549e8415c27cf1e0dd04dabc4f890021e180", + "graphSourceManifestSha256": "772d2ecb36c7a746ef7459dc4dbdb254dad9eae301857f93a3a726446ee159b7", + "baselineSha256": "9122c9b2f4f09b2b93653e320dbfd27f330bf859a3c3386a4cf462e1920aa74c", + "verification": { + "actualReadOnlyStandBaseline": true, + "composedLocalSuites": { + "host": 55, + "routeAndReadiness": 15, + "bridge": 8, + "coordinator": 15, + "adoption": 4 + }, + "driverChecks": 7, + "lateParentChecks": 5, + "actualPacketGraphChecks": 3, + "readinessCaptureChecks": 4, + "peerReviewConcreteBlockers": 0, + "actualSshForwardOrBrowserAcceptance": false + }, + "preserved": { + "fixedBrowserOrigins": true, + "internalNetworkIsolation": true, + "capsAndLoadAndOuterDeadline": true, + "originalManagerRestoreScope": true, + "originalPortalUnchanged": true, + "freshSyntheticInputsOnly": true + }, + "authorization": { + "userApproved": false, + "preparationsStarted": 0, + "managerWindowsStarted": 0, + "previousSpentGrantRejected": true + }, + "limits": { + "runtimeExecuted": false, + "uploaded": false, + "publishedImagesContainLatestDbStartupGuard": false, + "currentCodeWholeInstallation2GbAccepted": false, + "fullPlanComplete": false + }, + "nextStep": "Finish the current restore audit-import slice, prepare a clean current-code image snapshot, then bind its verified image digests before requesting final operating acceptance. The assembled old-image packet is preserved as a reviewed transport-validation snapshot." + }, + "restoreAuditRecovery": { + "date": "2026-11-03", + "status": "source-applied-genuine-owned-pg-http-accepted", + "patchSha256": "9d48461012aad8874f4438459c15d43f81b0ffb3b44035ac615a3f1c8bc27034", + "files": [ + { + "path": "backend/package.json", + "preimageSha256": "57fdf0f736348948560516ee6d8a90c04d6560680b5d269a1b60617b6ecda9f6", + "candidateSha256": "3171f6b046de630fa6edd0b7cf555594d3b746bbca059a80e7e09bc91c6247d5", + "appliedSha256": "3171f6b046de630fa6edd0b7cf555594d3b746bbca059a80e7e09bc91c6247d5", + "normalizedLfEquivalent": true + }, + { + "path": "backend/src/db/bootstrap.ts", + "preimageSha256": "3df7b060aa5d91b12bde822734688a26d5b3a18f2d6ace490aa805ac0a1018c0", + "candidateSha256": "ce2f7337dca5ead87e981b07ad20a12b9063d4c4d849e5172bea98d43ca9a6c3", + "appliedSha256": "ce2f7337dca5ead87e981b07ad20a12b9063d4c4d849e5172bea98d43ca9a6c3", + "normalizedLfEquivalent": true + }, + { + "path": "backend/src/modules/mcp/database-restore.ts", + "preimageSha256": "30863f664d18980bd3d1c262e2241b851c24b3f3cc3291aa84df538dcaf9604f", + "candidateSha256": "eb353c105cf22403d779dc4ce0e3bd9183ba991cfb9dce8f66e8f39ffaf629c7", + "appliedSha256": "ab657d9d045c1c679dae204b2c1ef654a30f674dfd4f53a7c7e74877a79c57e6", + "normalizedLfEquivalent": true + }, + { + "path": "backend/src/routes/mcp.ts", + "preimageSha256": "c5720b7cd5c3c5d1bfe0bb46d5aebc14893823d7cf66adb9550e2289a682091f", + "candidateSha256": "e4ce52af68b8d5f4632968298f3940528fe1d771ddc5cdae67d36a9bd9590304", + "appliedSha256": "22e2ae3fa952e1bc03050af406c5e79b90b6596c3864fa81efa9c6ea5d705f71", + "normalizedLfEquivalent": true + }, + { + "path": "backend/src/services/database-maintenance-startup.ts", + "preimageSha256": "bfa812186d33a9b413fcf7e6e640102d4277055c23b623a9e2ebfd26a06c2122", + "candidateSha256": "ca0d5fa98e8ca570fc54b2e256023233fe02e4667356b41df3bd93b452def9c7", + "appliedSha256": "ca0d5fa98e8ca570fc54b2e256023233fe02e4667356b41df3bd93b452def9c7", + "normalizedLfEquivalent": true + }, + { + "path": "backend/src/services/database-maintenance-startup.test.ts", + "preimageSha256": "a3782316a470bd09f1e65bb445f3250c07e5a9cff5ebc150da3a4375baffa0da", + "candidateSha256": "091c6ade22b4102f3ca700d75e068c63c24b5f59594c6b4b6f4147c8014ad376", + "appliedSha256": "091c6ade22b4102f3ca700d75e068c63c24b5f59594c6b4b6f4147c8014ad376", + "normalizedLfEquivalent": true + }, + { + "path": "backend/src/services/database-restore-audit.ts", + "preimageSha256": null, + "candidateSha256": "6e10e1ebdf31faa5f2a0db4808c81d4639523da83e4893c44d8fa10c6bfbea05", + "appliedSha256": "6e10e1ebdf31faa5f2a0db4808c81d4639523da83e4893c44d8fa10c6bfbea05", + "normalizedLfEquivalent": true + }, + { + "path": "backend/src/services/database-restore-audit.test.ts", + "preimageSha256": null, + "candidateSha256": "c0ac242c9981300209309af67c780f5866ad88a5ebdb35ac99e8311efe9c2169", + "appliedSha256": "c0ac242c9981300209309af67c780f5866ad88a5ebdb35ac99e8311efe9c2169", + "normalizedLfEquivalent": true + } + ], + "additionalControlPlaneInventory": "backend/mcp-coverage.json", + "behavior": { + "liveRestore": "After fsynced applied receipt, shared idempotent importer runs outside restore relabeling catch. Audit failures never turn confirmed restore into unknown.", + "startup": "Strict external guard before SQL; audit reconciliation after migrations and optional legacy import before business startup.", + "legacyActor": "Separate recovered event by database-maintenance-reconciler, exact receipt SHA, no invented original actor.", + "inspection": "Human settings-admin read-only audit-status route; not a model business tool.", + "lostCommit": "Destroy uncertain owner; next attempt reads existing evidence before insert. No restore replay.", + "retention": "Preserve original finishedAt and configured retention; expired audit not resurrected." + }, + "verification": { + "installedSystemDatabaseProfileExitCode": 0, + "backendBuildExitCode": 0, + "restCoverageExitCode": 0, + "coverage": { + "mediated": 200, + "excluded": 63, + "pending": 0 + }, + "startupAndAuditSelectedPassed": 26, + "startupAndAuditSelectedSkipped": 1, + "skipReason": "Host denied owned hardlink fixture", + "actualFreshProcessFilesystemChecks": true, + "sqlModelOnlyForImporter": false, + "genuinePgAccepted": true, + "genuinePgReport": "docs/mcp-restore-audit-genuine-acceptance-20261103.json" + }, + "failedOwnedPg": { + "firstRunBeforeFirstCompletedCase": true, + "setupCauseEstablished": false, + "importerBehaviorAccepted": false, + "retirementSource": "tmp/mcp-restore-audit-owned-pg-20261103/failed-run-retirement.json", + "retirementSourceSha256": "aeec6e14e339f3331738cc85d7f2134301663aad728d56d0cdfa802d60489dc8", + "nativeProcessesAndChildrenRetired": true, + "portFree": true, + "exactPartialDirectoryRemoved": true, + "workingDbOrRemoteTouched": false, + "nextStep": "Review a new bounded Windows-native runner and phase/SQLSTATE diagnostics before another isolated run." + }, + "openOriginalLifecycleGap": "Historical unknown/unconfirmed remains blocking after a later explicit successful restore. A human resolution workflow with known-current proof is still required; old outcomes must not be relabeled or ignored.", + "fullDbDirectionComplete": false, + "publishedImageUpdated": false, + "deployed": false + }, + "restoreAuditGenuineAcceptance": { + "date": "2026-11-03", + "status": "genuine-owned-postgres-http-accepted", + "sourceReport": "tmp/mcp-restore-audit-owned-pg-v2-20261103/acceptance-report.json", + "sourceReportSha256": "12be166f48c5d13358a5c6f7df6e8e7adf2a03860d75250fed03ec57e7cc22d2", + "actualExecSession": 48146, + "actualExecExitCode": 0, + "currentMigrationFiles": 44, + "casesPassed": 7, + "cases": [ + "Two genuine advisory-waiting importers produce exactly one row", + "Actual COMMIT followed by simulated response loss; new SQL owner reads first and does not duplicate", + "Genuine READ ONLY inspection has no INSERT", + "Expired evidence not resurrected by audit import", + "Conflicting genuine audit row refuses import", + "Production JWT/admin HTTP route allows settings-admin only and remains read-only", + "Actual bootstrap refuses unknown journal before SQL/connect" + ], + "actualCompiledBackend": true, + "absoluteProductionImportBindings": 6, + "compiledFixtureSha256": "5cb01a7581d1df1b46d85aac5242b72a2213457b46159d1fbc1096ffd269250c", + "emittedJsFiles": 1024, + "sourceAndEmittedInputsPinned": 2018, + "sourcePinsUnchanged": true, + "childExitCode": 0, + "childRetired": true, + "nativePgFastStop": true, + "nativeForceStopUsed": false, + "nativeIdentityAndDescendantsAndPortVerified": true, + "exactOwnedDirectoryRemoved": true, + "limitations": { + "commitResponseLossInjectedAfterRealCommit": true, + "authClaimsCallbackInertFixture": true, + "productionPortalAccepted": false, + "destructiveRestoreDispatched": false, + "workingDatabaseOrRemoteContacted": false, + "historicalUnknownResolutionComplete": false, + "fullDbDirectionComplete": false, + "publishedImageUpdated": false + } + }, + "executionEnvironment": { + "deploymentAcceptance": "Linux VPS 161.104.46.222", + "memoryLimitBytes": 2147483648, + "portalDeployedSeparately": true, + "windowsChecks": "Local functional/unit/SQL checks only; never installation capacity or target performance acceptance", + "newPerformanceRunsTarget": "Linux VPS", + "qemuResearch": "stopped; not part of current work", + "chrSequence": { + "currentRouters": 2, + "finishCurrentTwoRouterSchemeFirst": true, + "thirdRouterToBeProvidedByUserAfterCompletion": true, + "askForThirdOnlyAfterCurrentTwoRouterAcceptance": true + } + }, + "pollCompactorFunctionalCheck": { + "date": "2026-11-03", + "status": "local-functional-compaction-accepted-not-vps-performance-acceptance", + "platform": "Windows local functional SQL acceptance; started before user VPS clarification", + "actualExecSession": 8190, + "actualExecExitCode": 0, + "sourceReport": "tmp/mcp-poll-compactor-lock-profile-v2-20261103/attempt-2026-11-03T09-16-59-220Z-noSX7h/owned-acceptance.json", + "sourceReportSha256": "0b8a509ec0b3a54b38b42f4f5847322852bfafee8919d4277cc30cbeefff7308", + "actualMcpSources": 1, + "actualLocalhostNativeGets": 4, + "naturalOriginalExpiryAndSafetyWait": true, + "fakeClockOrHistoricalTimestampChanges": false, + "compaction": { + "scanned": 1, + "compacted": 1, + "skipped": 0, + "automaticReplay": false + }, + "foregroundCompleted": true, + "actualPgBlockingWitness": true, + "initialArchiveRows": 0, + "exactOriginalJournalAndTerminalReadback": true, + "sourcePinsUnchanged": true, + "actualNativeRetirementAndDirectoryCleanupVerified": true, + "measurementsScope": "Wrapped local diagnostic only; no Linux timing, target throughput, RAM or 62-archive V10 workload claim.", + "targetVpsPerformanceAccepted": false, + "installationMemoryAccepted": false, + "historicalV10CauseEstablished": false, + "full336Accepted": false, + "productLockOrQueryTimeoutChanged": false, + "nextStep": "Use existing Linux VPS for current-code compactor/load acceptance; no further Windows native harness work." + }, + "currentTwoChrAudit": { + "report": "tmp/mcp-two-chr-remaining-audit-20261103/README.md", + "thirdChrRequestDeferredUntilTwoChrAccepted": true, + "qemuWorkStopped": true + }, + "maintenanceResolution": { + "status": "component-linux-posix-sql-loopback-and-tls-accepted", + "report": "docs/mcp-maintenance-resolution-source-applied-20261103.json", + "latestFixReport": "docs/mcp-maintenance-namespace-fix-source-applied-20261103.json", + "nextLinuxPackageReport": "docs/mcp-linux-maintenance-v2-preparation-20261103.json", + "linuxAcceptanceComplete": true, + "linuxAcceptanceReport": "docs/mcp-linux-maintenance-resolution-acceptance-20261103.json" + }, + "routingTopologyObservation": { + "status": "source-applied-unit14-build-and-two-chr-readers-accepted", + "report": "docs/mcp-routing-topology-observation-source-applied-20261103.json", + "nativeOperationBoundAcquisitionComplete": false, + "nativeReport": "docs/mcp-two-chr-shared-fib-reader-acceptance-20261103.json", + "fibSwitchFixReport": "docs/mcp-routing-fib-switch-source-applied-20261103.json", + "sharedFibReport": "docs/mcp-routing-shared-fib-source-applied-20261103.json", + "nativeSourcePinsCurrent": true, + "genuinePublicMcpSqlHttpAccepted": true, + "sqlHttpReport": "docs/mcp-linux-routing-native18-acceptance-20261103.json" + }, + "linuxCriticalAcceptance": { + "report": "docs/mcp-linux-critical-acceptance-20261103.json", + "auditCasesPassed": 7, + "maintenanceLoopbackAccepted": true, + "maintenanceTlsAccepted": true, + "originalServicesUnchanged": true, + "remotePacketRemoved": true, + "maintenanceReport": "docs/mcp-linux-maintenance-resolution-acceptance-20261103.json" + }, + "databaseLiveAdmission": { + "report": "docs/mcp-database-live-admission-source-applied-20261103.json", + "status": "source-applied-permanent-lifecycle-http13-build-lint-passed", + "nativeAcceptanceComplete": false + }, + "routingOriginalEffects": { + "report": "docs/mcp-routing-original-effects-source-applied-20261103.json", + "status": "source-applied-component69-build-lint-passed", + "genuineSqlRuntimeAccepted": true, + "publicManualRevision2OwnerEnabled": false, + "scopedSchedulerEnabled": false, + "autoRecoveryEnabled": false, + "linuxComponentReport": "docs/mcp-linux-original-effects-acceptance-20261103.json" + }, + "oauthDatabaseAdmission": { + "report": "docs/mcp-oauth-database-admission-source-applied-20261103.json", + "status": "source-applied-http6-system-database-build-lint-passed", + "linuxCurrentImageAccepted": false + }, + "databaseDetachedAdmission": { + "report": "docs/mcp-detached-database-admission-source-applied-20261103.json", + "status": "source-applied-regression9-system-database-build-lint-passed", + "linuxCurrentImageAccepted": false + }, + "linuxRoutingEffectsAcceptance": { + "preparationReport": "docs/mcp-linux-routing-effects-preparation-20261103.json", + "attemptsReport": "docs/mcp-linux-routing-effects-attempts-20261103.json", + "status": "three-effects-and-routing18-components-accepted-terminal-replay-test-race-remains", + "firstAttemptCleanedAndOriginalBaselineConfirmed": true, + "accepted": false, + "wholeInstallation2GbAccepted": false, + "remainingAuthorizedRuns": 0, + "currentGrantMaxRuns": 3, + "currentGrantRunsUsed": 3, + "routing18Report": "docs/mcp-linux-routing-native18-acceptance-20261103.json" + }, + "routingAuthorityFenceBatching": { + "report": "docs/mcp-routing-authority-fence-batching-source-applied-20261103.json", + "status": "source-applied-native24-profile-build-lint-passed", + "genuineLinuxAccepted": false + }, + "routingForwardContextAdmission": { + "report": "docs/mcp-routing-forward-context-source-applied-20261103.json", + "status": "source-applied-bridge46-effects70-native-profile-build-lint-passed", + "genuineLinuxAccepted": false + } +} diff --git a/docs/mcp-database-live-admission-source-applied-20261103.json b/docs/mcp-database-live-admission-source-applied-20261103.json new file mode 100644 index 00000000..332b6ca4 --- /dev/null +++ b/docs/mcp-database-live-admission-source-applied-20261103.json @@ -0,0 +1,36 @@ +{ + "date": "2026-11-03", + "status": "source-applied-lifecycle-http13-build-lint-passed", + "patchSha256": "c06b311797bbcdefbc1b5ab526adeb3cc5c98b447f4c9fef8c22aa3a52ddcdec", + "files": [ + { + "path": "backend/src/services/database-request-gate.ts", + "appliedSha256": "03f9f8f6a50cdb1b20281281e8e59912924cb805980e54cc7b00cfd157006d86" + }, + { + "path": "backend/src/services/shared-database-gate.ts", + "appliedSha256": "3e5977c000b548ff6fcac66a7da2ea0dab5c420b3a0021e6c3c3872271145217" + }, + { + "path": "backend/src/services/system-database-backup.ts", + "appliedSha256": "c8731fc719cc98e904461d558b94df17c6e45772678fa91a311f275eb68ffd7e" + }, + { + "path": "backend/src/services/system-database-schema-readiness.test.ts", + "appliedSha256": "62db42b482b4e1bf395f54217a516ef4e6ecf97b910759dafc5f25d8c7ff2fa5" + } + ], + "originalRequirement": "mcp-implementation-plan.md section3.4: stop streams/background work, exclusive restore, durable outcome and restart reconciliation", + "behavior": "Unconfirmed schema or external maintenance completion keeps HTTP/MCP/worker admission and consumers closed after exclusive operation; explicit human repair remains available. Confirmation waits for migrations and external applied journal/close acknowledgement.", + "testsPassed": 13, + "permanentRegression": "backend/src/services/system-database-schema-readiness.test.ts", + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "independentSourceReview": "no concrete blockers; cycles/order/waiter/repair reviewed", + "genuinePgAcceptance": false, + "linuxCurrentImageAcceptance": false, + "deploymentPerformed": false, + "rawExternalSqlFenced": false, + "fullDbDirectionComplete": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-database-maintenance.md b/docs/mcp-database-maintenance.md new file mode 100644 index 00000000..93e8ce68 --- /dev/null +++ b/docs/mcp-database-maintenance.md @@ -0,0 +1,74 @@ +# Ручная проверка БД после неопределённого восстановления + +Команды `maintenance-inspect` и `maintenance-resolve` входят в текущий собранный оператор `backend/dist-operator/database-writer-profile-operator.mjs`. Их Linux/POSIX и PostgreSQL-приёмка прошла через локальное подключение и TLS: [16 сценариев](mcp-linux-maintenance-resolution-acceptance-20261103.json). Опубликованный ранее preview-образ этим отчётом не обновляется. + +После неопределённого восстановления обычные HTTP/MCP-запросы, планировщик и приём NetFlow остаются закрытыми. Существующий административный путь явного восстановления доступен. Не повторяйте прежнее действие автоматически: отсутствие ответа не означает, что данные не изменились. + +Этот порядок позволяет человеку принять **наблюдаемое текущее состояние**. Он не доказывает соответствие данных исходному архиву и не превращает старые `unknown` в успешные операции. + +## Подготовка + +1. Остановите управляемый backend штатным способом. Сохраните его состояние и параметры запуска. PostgreSQL должна остаться доступной оператору; параллельное обслуживание БД недопустимо. +2. Выберите проверенный образ с текущими командами оператора. Используйте существующий том состояния MCP, а не новый пустой том. +3. Запускайте оператор от фактического владельца тома, с тем же UID, что использует backend. Каталоги должны принадлежать ему и быть закрыты от других пользователей; файлы входных данных — иметь права `0600`. Команда не исправляет владельцев и права автоматически. +4. Подготовьте отдельный закрытый каталог входных данных и отдельный каталог результатов. Монтируйте их только в контейнер оператора, не в обычный backend/MCP. Пароли передаются в защищённом файле транспорта, не в аргументах команды. + +Нужны два независимо проверенных файла: + +- `runtime-target.json`: `format=database-maintenance-runtime-target-v1`, `databaseUrl` с адресом, портом и именем настроенной БД **без имени пользователя и пароля**. Это конфигурация назначения backend, а не произвольная другая БД. +- `transport.json`: `format=database-maintenance-transport-v1`, выбранный `mode` и `connection` с полями `host`, `port`, `user`, `password`, `database`. + +Для `mode=tls` дополнительно нужны `tls.serverName`, `tls.spkiSha256`, защищённый CA-файл и его проверенный SHA256. TLS — режим по умолчанию; после его ошибки переключения на незашифрованное соединение нет. + +Для обычной Docker-установки без TLS поддерживается явный `mode=local-loopback`. Отдельный контейнер оператора должен присоединиться к сетевому пространству **точного проверенного контейнера PostgreSQL**: `--network "container:$REVIEWED_PG_ID"`. В `connection.host` допустимы только `127.0.0.1` или `::1`; параметры CA в этом режиме запрещены. Host networking и Docker socket не нужны. Настроенное назначение backend в `runtime-target.json` при этом сохраняется. + +## Инспекция + +В отдельном контейнере оператора выполните команду с проверенными путями и SHA256: + +```sh +node backend/dist-operator/database-writer-profile-operator.mjs maintenance-inspect \ + --state-dir /app/data/mcp \ + --transport /run/review/transport.json --transport-sha256 "$TRANSPORT_SHA256" \ + --runtime-target /run/review/runtime-target.json --runtime-target-sha256 "$TARGET_SHA256" \ + --output /run/output/inspection.json +``` + +Для TLS добавьте `--ca /run/review/ca.pem --ca-sha256 "$CA_SHA256"`. `/app/data/mcp` — пример: используйте фактический `MCP_STATE_DIR` установки. + +Инспекция только читает БД. Она проверяет физическую идентичность PostgreSQL, текущие миграции и схему, внешний журнал, поколение доступа и квитанции. Необязательная служебная схема `mmapp_control` не требуется для обычной установки. Результат содержит частные сведения о схеме; храните его в закрытом каталоге. + +Если `humanApprovalRequired=false` и состояние уже принято, повторное разрешение не требуется. Не создавайте вторую запись ради подтверждения потерянного ответа. + +## Решение человека + +Человек отдельно проверяет инспекцию и создаёт защищённый `approval.json` со следующими полями: + +```json +{ + "format": "database-maintenance-current-state-approval-v1", + "resolutionId": "<новый UUID>", + "decision": "accept-observed-current-state", + "actorId": "<идентификатор оператора>", + "inspectionSha256": "<проверенный SHA256 inspection.json>", + "issuedAt": "", + "expiresAt": "", + "managedBackendStopped": true, + "historicOutcomesRemainUnknown": true, + "automaticRestore": false +} +``` + +Приложение и модель не создают это решение за человека. Проверенный SHA256 разрешения передаётся отдельно. Инспекция и разрешение должны оставаться действительными до публикации решения. + +Вызовите `maintenance-resolve` с теми же параметрами назначения и транспорта, добавив `--inspection FILE --inspection-sha256 SHA --approval FILE --approval-sha256 SHA`. Параметр `--output` здесь не используется. + +Оператор повторно читает текущее состояние, сверяет его с инспекцией и разрешением, завершает SQL-владельца и публикует неизменяемую fsync-запись решения вне PostgreSQL. Исходные журнал, квитанции и поколение доступа не переписываются. DDL, восстановление архива и выдача дополнительных SQL-прав не выполняются. + +## Возобновление и повторная проверка + +После подтверждённого решения запустите backend штатным способом и проверьте его готовность. Проверка при старте должна разрешить только точно покрытую историческую неопределённость. Новая незавершённая операция, изменение покрытых файлов, другое назначение БД, повреждённая запись или оставшийся lease снова блокируют запуск до SQL. + +Если ответ `maintenance-resolve` потерян, начните с новой инспекции: существующее историческое решение должно быть прочитано, а не опубликовано повторно. Не удаляйте lease или записи решения по возрасту/PID. Подтверждённое историческое решение сохраняет силу после истечения срока использованного разрешения; новое решение требует нового разрешения. + +Исторические `unknown` остаются такими в квитанциях. В SQL-аудит импортируются только действительно подтверждённые квитанции `applied`; при неизвестном исходном авторе используется отдельная служебная запись сверки. diff --git a/docs/mcp-detached-database-admission-source-applied-20261103.json b/docs/mcp-detached-database-admission-source-applied-20261103.json new file mode 100644 index 00000000..a3c34180 --- /dev/null +++ b/docs/mcp-detached-database-admission-source-applied-20261103.json @@ -0,0 +1,51 @@ +{ + "date": "2026-11-03", + "status": "source-applied-regression9-system-database-build-lint-passed", + "patchSha256": "077a4ba67ebce2777b77d06d3377f4222670baf398169dbbf7493c784c7bcdc8", + "files": [ + { + "path": "backend/src/services/database-request-gate.ts", + "appliedSha256": "906480322a25eaa20957725eb02dc52d9eed6ad52846378bb0657afa39952364" + }, + { + "path": "backend/src/services/system-database-backup.ts", + "appliedSha256": "1c80b893a60d0e72fe63fca924e7740e0bf918637d94aa4a316e87ef5533e4ca" + }, + { + "path": "backend/src/modules/mcp/operation-execution-origin.ts", + "appliedSha256": "beed7abf1dac0b325790ba6cc103c35fe86094ab8fa3cfad846a0b9f7cbfe1d7" + }, + { + "path": "backend/src/modules/mcp/database-restore.ts", + "appliedSha256": "c0f386c95cd333715cd4cae7155f055743815edf745551069d604dd7e6597032" + }, + { + "path": "backend/src/services/detached-database-admission.test.ts", + "appliedSha256": "79871b92fdb6b4d2ab3808073974492333ce04f80b46b4a21a7cbafce561b116" + }, + { + "path": "backend/package.json", + "appliedSha256": "21cfd7026d487a6d0dcb96f79d76361790da07d17982a7e003ceaec79adc4001" + } + ], + "originalGap": [ + "Detached interruption cleanup could use SQL outside database admission after runner refusal.", + "Terminal restore claimed its operation before acquiring the shared exclusive boundary." + ], + "rejectedV1": "An inherited live-admission boolean did not retain ownership across delayed checkout when its detached parent settled.", + "correction": "Retained continuations register synchronously in the active admission and drain before request/exclusive retirement, including descendants. Cleanup waiting for repair releases runner slots. Matching inner restore retains exclusive lifetime. Terminal first checkout and advisory claim are inside exclusive; late cleanup SQL dispatch refuses.", + "focusedPermanentCasesPassed": 9, + "existingGateReadinessOriginChecksPassed": 25, + "systemDatabaseProfileExitCode": 0, + "systemDatabaseNativePosixCaseSkippedOnWindows": true, + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "noNewDatabaseRolesOrSourceFramework": true, + "rawExternalSqlFenced": false, + "genuinePgAccepted": false, + "linuxCurrentImageAccepted": false, + "originalServicesChanged": false, + "deploymentPerformed": false, + "fullDbComplete": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-implementation-plan.md b/docs/mcp-implementation-plan.md new file mode 100644 index 00000000..2d7de27b --- /dev/null +++ b/docs/mcp-implementation-plan.md @@ -0,0 +1,1688 @@ +# MCP для MikrotikManager — итоговый план реализации + +Актуально на 3 ноября 2026. **Полный план не завершён: открыты семь направлений.** Проверенного общего процента и точного числа оставшихся независимых задач нет. [Текущий статус](mcp-current-progress.json) · [Доказательства](mcp-evidence-statistics-20261101.md). + +Последние завершённые работы: + +- **Новые текущие результаты:** Linux POSIX/SQL-приёмка обслуживания БД завершена для loopback и TLS:16 сценариев, исходные сервисы сохранены и пакет удалён. В исходниках закрыт повторный допуск запросов после неопределённого restore;13 постоянных lifecycle/HTTP-проверок и сборка прошли. Текущий reader топологии принят на обоих CHR:90 GET, положительный следующий шлюз и сохранность17 разделов на каждом; исправлен фактический REST-формат FIB switch. Это не полная рекурсивная цепочка, BGP origin, forwarding или вся двухузловая схема. [Linux](mcp-linux-maintenance-resolution-acceptance-20261103.json) · [Допуск БД](mcp-database-live-admission-source-applied-20261103.json) · [Два CHR](mcp-two-chr-current-reader-acceptance-20261103.json). +- **EXT-2 — внешние MCP-клиенты — закрыт по исходному требованию.** Настоящий Inspector HTTP/stdio, OAuth PKCE, refresh/replay, rotation/revoke/reconnect, ограничения схем/результатов и изоляция от REST/admin проверены. Все363 описания инструментов проверены для двух согласованных версий протокола. [Приёмка](mcp-external-clients-completion-20261103.json). В EXTERNAL-UI остаются EXT-1, UI-1, UI-2 и RAM-1. +- **Текущий оператор БД функционально принят на Linux:** preflight, подписанный профиль/current44, TLS/POSIX и сверка после потери подтверждения COMMIT без приватного ключа и при истёкшем исходном разрешении. Потеря подтверждения моделировалась на уровне приложения. Ресурсная часть отказала из-за роста swap. Использован один из трёх разрешённых прогонов; повтор успешного функционального сценария не нужен. [Отчёт](mcp-publisher-current-operator-linux-acceptance-20261103.json). Production writer/generation/READY этим не выдаются. +- **Готовый оператор включён в Docker-сборку:** 17 модулей оператора и9 модулей backend; TypeScript/tsx во время выполнения не нужны. Сборка backend и две проверки артефакта на Node22 прошли. [Проверки](mcp-operator-build-ci-source-applied-20261103.json). +- **Два очищенных снимка опубликованы с явного согласия пользователя.** Только отдельные ветки codex/mcp-acceptance-clean-20261103, без родительской истории и локальных настроек. В4146 файлах предоставленных паролей нет. Preview CI backend/frontend прошёл; для каждого образа проверены digest, Linux/amd64 и квитанция всех3835 Git-объектов. Портал также успешно собран: прогон2586, попытка2, вручную запущенная пользователем; первая попытка отменена. Для него проверены311 Git-объектов и отдельный OCI digest. Все три Linux-образа готовы, повторных запусков агент не отправлял. [Публикация и текущие CI-доказательства](mcp-preview-publication-20261103.json). Основные ветки/latest/действующие сервисы не являются целью этих workflows. +- **Portal sender/outbox → настоящий TLS → MCP receiver/собственная PG принят на синтетическом стенде.** Каталог прав портала и его сборка также проверены. Это не приёмка действующего портала или полного браузерного потока. [Sender](mcp-portal-sender-genuine-acceptance-20261103.json) · [Каталог](mcp-portal-permission-catalog-build-20261103.json). +- **Для проверки всей установки только чтением зафиксированы четыре исходных контейнера:** точные ID/image, состояние/health, restart policy и контрольные суммы конфигурации/томов/сетей. У действующих контейнеров Docker Memory/MemorySwap равны нулю; планируемые caps не являются текущими настройками. Новый кандидат backend512/PG320/frontend160/portal256 МиБ + резерв672 МиБ основан на дополнительном замере памяти ядра; сумма1920 МиБ, остаток около54 МиБ. Прежний вариант384/160/512 сохранён как исторический. Работа под нагрузкой не принята. [Исходный стенд](mcp-whole-installation-baseline-ledger-20261103.json). Компонент восстановления четырёх сервисов прошёл семь локальных fault-проверок; подключение к существующему управляющему процессу подготовлено и проверено локально; последующие прогоны старого снимка восстановили исходные сервисы, но smoke/full не приняты; текущему коду ещё нужна полная приёмка установки. [Компонент](mcp-four-service-recovery-component-20261103.json). + +Дальнейшая очередь: + +1. Завершить исполняемый пакет frontend/backend+MCP/PostgreSQL для сервера с 2 ГБ; портал работает отдельно. Сохранить проверку настоящего входа/прав и полный сценарий UI/MCP/нагрузки, не останавливая исходный портал стенда. +2. Завершить all-writer/source-generation lifecycle БД и production admission. Подписанный профиль, preflight и упаковка оператора не заменяют эти требования. +3. Продолжить разбор POLL336. NORMAL V10 завершился STATE_UNAVAILABLE после68 операций; причина исторического отказа не доказана. На отдельном настоящем PG воспроизведён timeout глобальной storage-lock за5039мс; закрытая диагностика включена в исходники. Это механизм отказа, а не доказанное исправление V10. Новый полный прогон без изменения причины не запускается. [Диагностика](mcp-poll-storage-lock-diagnostic-source-applied-20261103.json). +4. Закрыть ROUTING/NATIVE/PATHS/EXTERNAL-UI и итоговую интеграцию по исходным требованиям. Доступны только два CHR и REST /log; третий независимый узел и полнота событий ими не подтверждаются. Публичное autoRecovery выключено. Установка MikrotikManager + MCP + PostgreSQL ≤2 ГБ остаётся открытым требованием; отдельный сервер портала в этот бюджет не входит. + +Ранее приняты текущие44 миграции/137 primary-групп, настоящее encrypted restore/tamper и natural archive, составное покрытие38 профилей/VRF68, миллион строк журнала, UI подтверждения плана и настоящее15-минутное истечение файла с полным завершением Job. Это отдельные результаты, не доказательство полного завершения. Широкие успешные проверки без изменений не повторяются. + +В публичной main обнаружен ранее предоставленный тестовый пароль CHR в локальном агентском конфиге. Старые содержащие его снимки не публиковались; новые коммиты созданы без этой истории. Смена действующего доступа и очистка старой публичной истории ещё не выполнялись. + +[Подготовлен конкретный четырёхсервисный кандидат](mcp-whole-installation-compose-candidate-20261103.json), привязанный к трём проверенным Linux-образам и точному существующему образу PG. TLS уведомлений портала работает внутри основного backend-потока/его512 МиБ, без пятого контейнера. Настоящий Node22 Worker подтвердил, что preload в рабочих потоках не читает ключи и не создаёт повторный listener. Контейнеры не запускались. Новые синтетические credentials/certificates, проверка собственных пустых томов/портов и связанный UI/workload ещё готовятся. + +[Измерена память ОС только чтением](mcp-whole-installation-host-memory-20261103.json): PSS процессов вне четырёх исходных контейнеров около519 МиБ, в том числе Docker около372 МиБ; включён кратковременный наблюдатель, ядро и файловый кеш сюда не входят. Дополнительный замер PSS и выбранных расходов ядра дал586.54 МиБ; новый плановый резерв672 МиБ учитывает управляющий процесс и оценку Docker CLI. Это не подтверждённый эксплуатационный бюджет. Настройки Docker, исходные контейнеры и рабочая БД не менялись. + +[Проверен staged parent восстановления четырёх сервисов](mcp-four-service-parent-review-20261103.json): после выхода управляющего процесса числовые идентификаторы оставшихся процессов только наблюдаются; недоказанная принадлежность запрещает сигналы, восстановление и очистку. До подтверждённого завершения дочерней группы исходные ресурсы не затрагиваются. После правки прошли три затронутые проверки; предыдущие16 компонентных результатов сохранены отдельно. Запуск parent остаётся отключённым до подготовки полного пакета. + +[Продолжена подготовка единого пакета](mcp-whole-installation-package-preparation-20261103.json): созданы закрытые синтетические inputs/CA, проверено анонимное чтение трёх образов; локальные проверки ограниченного файлового транспорта прошли. Новый [кандидат профиля](mcp-whole-installation-measured-profile-candidate-20261103.json) сохраняет все четыре сервиса при сумме1920 МиБ. Controller/workload/UI ещё staged; полный сценарий не исполнялся и пакет для окна пока не готов. + + +**Уточнение топологии от пользователя:** портал установлен на отдельном сервере. В бюджет MikrotikManager + MCP входят frontend, backend и PostgreSQL; прежний четырёхсервисный кандидат сохранён как история. Новый расчёт: 992 МиБ caps приложения + 672 МиБ резерв = 1664 МиБ, около310 МиБ остатка на измеренном хосте. Это расчёт, не принятая нагрузка. Вход, права и уведомления портала остаются в интеграционной проверке; повторная полная оценка памяти портала не требуется. [Точная граница](mcp-separated-portal-topology-20261103.json). + + +**Текущее определение источника маршрутов проверено на PostgreSQL:** 14 SQL/HTTP-сценариев, 126 запросов без native writes; настоящий encrypted dump/restore и отказ подменённому архиву прошли. Исправлены только временные метки тестового сценария, production-проверки не ослаблялись. Проверки parser/assembly: 88 прошли. Реальные маршрутизаторы и полноту событий это не подтверждает. [Приёмка](mcp-native-route-origin-sql-acceptance-20261103.json). + + +**Архив приёмки собран:** 26 файлов, 63829 байт; все хеши, состав и Unix-права проверены независимо. Он останавливает/восстанавливает только исходные frontend/backend/PostgreSQL; исходный портал не меняется. Собственный синтетический Portal сохранён как вспомогательная нагрузка, его память из наблюдений не вычитается. Внешний coordinator связан с браузером и серверным launcher: восемь локальных проверок прошли, при неизвестном результате каналы мониторинга сохраняются. Загрузки или удалённого запуска не было. [Точный пакет](mcp-manager-operating-package-20261103.json). + + +**Разрешённая operating-приёмка завершилась отказом:** использованы три подготовки и одно окно остановки manager. Две preflight-ошибки исправлены; третий прогон остановился при старте Compose до smoke/full. Исходные ID/config/mount/host/restart policy восстановлены, frontend307/backend200/portal200 и PostgreSQL healthy проверены независимо, собственные ресурсы удалены. Legacy-сверка отказала из-за динамического сетевого хеша трёх перезапущенных сервисов; исходный портал совпал полностью. Новый запуск не разрешён использованным grant. [Точные результаты и границы](mcp-manager-operating-attempts-20261103.json). + +**Дополнительно реализованы:** защищённый read-компонент реестра источника БД включён в18-модульный deploy artifact (9 focused + Node22 artifact checks прошли; настоящий Linux POSIX ещё не принят). SQL-профиль маршрутизации принял24 прежние группы и новую гонку forward/manual inverse за общий последний слот; проигравший не получил operation/charge и не списал бюджет. [БД](mcp-protected-source-registry-component-20261103.json) · [Маршрутизация](mcp-routing-mixed-admission-20261103.json). + + +**Исправления после отказа подготовлены локально:** сеть намерений отделена от динамических адресов (7 mock-проверок + новый настоящий GET-only снимок); ранний UI cleanup сохраняет наблюдение восстановления без fixture (7 pure-проверок); bounded private диагностика вызывается до очистки own-контейнеров (8 локальных проверок). Это будущие компоненты, не принятая повторная приёмка и не готовый следующий пакет. Причина фактического cold-start пока не доказана. [Follow-up](mcp-operating-recovery-followup-20261103.json). + + +**Следующий пакет уже собран из связанного кода:** новый instance и синтетические ключи, 27 файлов/68937 байт. Реально подключены v2 ledger/restorer, создание вложенной mountpoint до Docker create и сбор закрытой диагностики до удаления контейнеров; новый координатор сохраняет её до удаления серверного каталога. Проверены55 offline host checks,14 контрактов координатора и2 прямых cross-component случая; числа имеют пересекающиеся области и не складываются в общий счётчик. Лимиты/нагрузка/образы прежние, runtime не выполнялся. [Пакет](mcp-operating-next-package-20261103.json). + + +**Следующий реальный прогон запустил все четыре own-сервиса:** они работали без наблюдаемого OOM, v2 восстановил исходные сервисы и сеть намерений; own-ресурсы удалены. Smoke/full не начались: Playwright использовал TEMP родительского Node и попал под строгий browser-temp guard. Исправление принято отдельным настоящим Edge smoke:2 контекста, kernel0, handles освобождены и исходный owner очистил каталог. Исправлена и гонка позднего parent completion. [Прогон](mcp-operating-next-run-20261103.json) · [Настоящий браузер](mcp-ui-parent-temp-native-acceptance-20261103.json). + +**Подготовка последнего пакета:**27 файлов/68933 байта, свежий baseline/instance, native-проверенный UI и15 локальных driver checks. Независимое review не нашло конкретного блокера; затем пользователь разрешил один прогон. Это разрешение использовано, результат приведён ниже. [Точный пакет](mcp-operating-browser-fixed-package-20261103.json). За время ожидания protected registry подключён к настоящей read-only команде preflight:29 selected checks и Node22 artifact passed; Linux/READY/all-writer по-прежнему не приняты. [Preflight](mcp-protected-registry-preflight-source-applied-20261103.json). + + +**Последний разрешённый прогон выполнен:** пакет512529 запустил четыре собственных сервиса без наблюдаемого OOM; настоящий Edge успешно запущен и завершён. UI остановился до проверки учётных данных: SSH-туннель получил Connection refused от тестового Portal endpoint. Внутренний bootstrap портала прошёл; причина host port publishing пока не доказана. Исходные сервисы восстановлены с v2-проверкой, собственные серверные ресурсы удалены, закрытая диагностика сохранена. Локальный каталог UI также точно очищен после сохранения доказательств; процессы завершены, дескрипторы освобождены. Разрешение на один прогон использовано. Smoke/full и вся установка ≤2 ГБ не приняты. Следующий шаг — проверка опубликованных портов и HTTP через туннель до запуска браузера. [Результат](mcp-operating-browser-run-20261103.json). + + +**Защита запуска после прерванного восстановления БД включена в исходники:** проверка внешнего журнала и квитанций выполняется до SQL/миграций/задач. Неопределённая история блокирует старт без повторения restore; завершение ядра не подтверждает импорт аудита или готовность схемы. Профиль system-database и сборка backend прошли; новые проверки:15 passed,1 skip из-за запрета создания hardlink. Проверка фактического прерывания PostgreSQL, разрешение заблокированной истории и общий DB lifecycle остаются открыты. Опубликованные preview-образы этого изменения ещё не содержат, деплоя нет. [Проверки и границы](mcp-maintenance-startup-source-applied-20261103.json). + + +**Исправление тестового туннеля подготовлено локально:** прежние адреса браузера сохранены; SSH обращается к проверенным адресам собственных контейнеров внутри изолированной сети. Перед planned restart блокируются новые подключения и ожидается подтверждение, после — повторно проверяются ID/сеть/адрес.15 mock-проверок прошли, включая настоящий код обработчика с управляемой гонкой подключения/ACK. Это ещё не связанный пакет или приёмка реального туннеля; новый архив не собран, разрешение не расширено. [Кандидат и границы](mcp-own-web-transport-candidate-20261103.json). + + +**Туннель подключён к новому связанному пакету:** свежий GET-only baseline9122;29 файлов/31 tar member, новые синтетические учётные данные и TLS. Проверены host/bridge/coordinator и реальные точки bootstrap/restart с моделируемым внешним исполнением; запускатель принял19 локальных проверок, независимое review не выявило блокеров. Старое разрешение отклоняется, новое false/0. Пакет не загружался. Он пока с прежними bf3d-образами, поэтому для окончательной приёмки текущего кода требуется свежая preview-сборка после завершения переноса restore-квитанций в аудит. [Пакет и точные границы](mcp-routed-operating-package-20261103.json). + + +**Квитанция восстановления → аудит подключена к исходникам:** живой restore и startup используют общий idempotent importer; сбой аудита не меняет подтверждённый исход restore, потеря COMMIT ACK требует новой READ-first сверки. Неизвестный исход блокируется до SQL, историческому событию не приписывается выдуманный человек. Профиль system-database, build и покрытие263 REST-маршрутов прошли:200 mediated/63 control-plane excluded/0 pending.26 новых файловых/SQL-model проверок прошли,1 hardlink skip. Настоящий PG-прогон не принят: отказ setup до первого завершённого case; точные тестовые процессы и каталог независимо очищены, дефект Windows test-runner исправляется отдельно. Общий DB lifecycle и явное разрешение исторических unknown остаются открыты. [Проверки и границы](mcp-restore-audit-source-applied-20261103.json). + + +**Настоящая PG/HTTP-приёмка импорта restore-аудита пройдена:** current44,7 сценариев: конкурирующие транзакции дают одну запись; потеря ответа после настоящего COMMIT не создаёт дубль; read-only/retention/conflict/JWT-admin и отказ unknown до SQL проверены. Native stop завершился штатно, actual child/внешний процесс0, потомки и порт проверены, точный каталог удалён.2018 source/emitted входов не изменились. Это не приёмка настоящего портала, аварийного psql restore или разрешения исторических unknown; полный DB остаётся открыт. [Доказательства](mcp-restore-audit-genuine-acceptance-20261103.json). + + +**Уточнён порядок приёмки пользователем:** память и нагрузка проверяются на Linux VPS MikrotikManager, целевая установка максимум2 ГБ; портал отдельно. Windows-проверки — только функциональные, не приёмка вместимости или производительности VPS. Сначала завершается текущая схема с двумя CHR; затем нужно попросить пользователя о доступе к третьему узлу, который он предоставит для следующего этапа. Анализ QEMU прекращён, установка VM не планируется. Исходное требование трёхузловой проверки сохранено. + + +**Уже запущенная локальная функциональная проверка poll/compactor завершена:** один настоящий MCP-source,4 localhost GET, естественное истечение исходного допуска, один compactor и read-back исходного журнала/архива; конкурирующая storage-транзакция завершилась, PG blocker наблюдён. Процесс0, PostgreSQL и точный каталог очищены. Это не измерение RAM/производительности VPS и не воспроизведение62 архивов V10;336 остаётся открыт. Дальнейшая нагрузочная приёмка — на Linux VPS. [Функциональное доказательство](mcp-poll-compactor-local-functional-20261103.json). + + +**Проверка эффекта переключения включена в исходники:** 69 компонентных проверок, существующий native-readers, сборка и lint прошли. Исполнение revision3 сохраняет подписанное наблюдение до PATCH и два после записи; неизвестный результат или отсутствие фактической смены endpoint запрещают продвижение baseline. Обычный ручной revision2 и автоматический scoped scheduler ещё не активированы; исходный защитный запрет сохранён. Настоящая SQL/runtime-приёмка подготовлена, но пока не выполнена. [Отчёт](mcp-routing-original-effects-source-applied-20261103.json). + +**OAuth включён в общий барьер обслуживания БД:** шесть настоящих Fastify-проверок и весь существующий профиль system-database прошли; discovery остаётся доступным. Это функциональные проверки, не Linux-приёмка текущего образа. [Отчёт](mcp-oauth-database-admission-source-applied-20261103.json). + + +**Закрыты два обхода барьера БД:** detached cleanup теперь удерживает допуск до завершения SQL даже после окончания родительского обработчика, а terminal restore получает exclusive до первого SQL. Девять новых регрессий, 25 существующих проверок, system-database, сборка и lint прошли. Отказавший вариант с одной boolean-проверкой не включался. Настоящая Linux/PG-приёмка текущего кода остаётся отдельным этапом. [Отчёт](mcp-detached-database-admission-source-applied-20261103.json). + + +**Linux-компоненты маршрутизации приняты:** три genuine SQL/runtime-сценария эффекта и18 SQL/HTTP-сценариев чтения, включая4 публичных Bearer/runtime topology группы. Read-only этап выполнил151 запрос без native writes и сохранил точный SQL JSON inventory трёх настоящих checkpoints. Ответы RouterOS в этих сценариях синтетические localhost; реальные два CHR проверены отдельно reader-приёмкой. Public manual revision2 и scoped scheduler не активированы, autoRecovery выключено. Terminal restore реально дошёл до applied и отзыва токенов, но тест stale callback успел запросить exclusive до завершения прежнего owner и получил409. Тест исправлен на ограниченное ожидание фактического освобождения барьера после applied/blocked. Прежние разрешённые прогоны использованы; готовится отдельная оставшаяся restore-only приёмка, без повторения успешных routing-групп. [Эффекты](mcp-linux-original-effects-acceptance-20261103.json) · [Чтение](mcp-linux-routing-native18-acceptance-20261103.json) · [Попытки](mcp-linux-routing-effects-attempts-20261103.json). + + + +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards, ограниченная выдача крупных полей журнала MCP и durable forward-charge с миграцией43. [Текущая локальная приёмка](mcp-current43-regression-restore-acceptance-20261101.json) прошла:38 профилей (36 +2 после test-only corrections),68 VRF-групп, текущие schema/data/control SQL lanes и настоящее primary encrypted restore/tamper. Полная промышленная/сетевая/UI-приёмка всего плана этим не подтверждена. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Новый capacity336 после принятого natural-expiry smoke, затем faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [Общий routing admission](mcp-routing-common-admission-source-applied-20261101.json):4 SQL-сценария и1 настоящий MAIN HTTP/runtime PASS. Persistent MAIN/scopedfalse используют тот же порядок profile/credential/plan locks; старый план после смены профиля отклоняется, reserved2slots/debit сохранены. Правка подключена, build/lint/48 проверок PASS. Общий ledger всех writers ещё не завершён, родительский RT-3 остаётся открытым. +- [Logs lifecycle](mcp-logs-live-reset-acceptance-20261101.json):1 настоящий SQL/auth/HTTP/SSE-сценарий, два пассивных подписчика по5201 записи, actual UI merge5000. Раздельно доказаны manual response close, maintenance reset/EOF без клиентского abort, drain и новая история после gate release. Все ресурсы очищены; browser/mounted hook, миллион логов и весь UI-2 этим не приняты. +- [Подписанный профиль БД](mcp-db-protected-profile-issuer-acceptance-20261101.json):5 настоящих SQL-групп на PG18.4,42 миграции, реальный flow engine/live pair, future leaf/CAS/истечение срока/tamper/lost COMMIT ACK без повтора. Raw hashes/завершение процессов/удаление собственной БД проверены. Это ограниченный staged component114 tables: production host/каталог/стоимость/all-writer/READY ещё не приняты. [Точные остатки и выявленные ограничения](mcp-db-profile-issuer-production-map-20261101.md): штатный warmup2193constraints уже превышает старый cap2048; перенос без paging и проверки стоимости не выдаётся за готовность. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336 v5:** running; на момент 2026-11-01T07:20:23.280Z создано 82 операций, подтверждено 75/336 архивов. Новый независимый прогон использует замороженную Windows-сборку с42 миграциями и естественное истечение допуска; это не проверка будущей43 миграции, Linux или всей установки≤2GB. [Предварительный настоящий smoke](mcp-poll-capacity336-v5-smoke-acceptance-20261101.json) принят:2 операции,1 архив,8 native GET,0 ошибок обработчика, PostgreSQL остановлена и собственные ресурсы удалены. [Предыдущий отказ v4](mcp-poll-capacity336-v4-refusal-20261101.json) сохранён отдельно; его неизвестный результат не повторяется. + +**Новые промежуточные результаты:** [durable forward-charge](mcp-routing-forward-charge-source-applied-20261101.json) подключён цельным патчем16 файлов с миграцией43 после6 настоящих SQL/HTTP-групп на штатном пути миграции. Build0, manifest20, controlled migration14 и routing model9 PASS. [Непустой backup/restore](mcp-routing-forward-restore-partial-acceptance-20261101.json) сохраняет charge/HMAC; [после принудительного перезапуска](mcp-routing-forward-charge-forced-restart-scoped-20261101.json) charge/op/debit/HMAC и все строки таблиц равны, но строгое полное сравнение отказало из-за2/7 sequence digests (направление/причина не измерены). Исправление трёх UUID seed-предикатов прошло unit8/build/lint/reference11 + encrypted restore/tamper; происхождение UUID не аттестовано, READYfalse. [Отдельный независимый reference43](mcp-db-explicit43-reference-data-acceptance-20261101.json) создан и проверен как TEST DATA103 bases/14 new constraints; production issuer не включён. Cost run сначала отказал на необязательной memory-диагностике42501 без расширения grants; готовится повтор без неё. Mounted artifact journey: public Next и настоящий backup producer прошли, browser provider отказал; затем отдельный Edge preflight прошёл с actual root/tree/close/cleanup. Полный download/revoke ещё не выполнен; один старый owned-каталог сохранён из-за неполного доказательства browser cleanup. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **paged TypeScript-каталог БД → durable forward-charge/общий routing ledger → mounted artifact download/revoke**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards и ограниченная выдача крупных полей журнала MCP. Последняя сборка прошла; общая финальная регрессия после последних изменений ещё обязательна. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Новый capacity336 после принятого natural-expiry smoke, затем faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [Общий routing admission](mcp-routing-common-admission-source-applied-20261101.json):4 SQL-сценария и1 настоящий MAIN HTTP/runtime PASS. Persistent MAIN/scopedfalse используют тот же порядок profile/credential/plan locks; старый план после смены профиля отклоняется, reserved2slots/debit сохранены. Правка подключена, build/lint/48 проверок PASS. Общий ledger всех writers ещё не завершён, родительский RT-3 остаётся открытым. +- [Logs lifecycle](mcp-logs-live-reset-acceptance-20261101.json):1 настоящий SQL/auth/HTTP/SSE-сценарий, два пассивных подписчика по5201 записи, actual UI merge5000. Раздельно доказаны manual response close, maintenance reset/EOF без клиентского abort, drain и новая история после gate release. Все ресурсы очищены; browser/mounted hook, миллион логов и весь UI-2 этим не приняты. +- [Подписанный профиль БД](mcp-db-protected-profile-issuer-acceptance-20261101.json):5 настоящих SQL-групп на PG18.4,42 миграции, реальный flow engine/live pair, future leaf/CAS/истечение срока/tamper/lost COMMIT ACK без повтора. Raw hashes/завершение процессов/удаление собственной БД проверены. Это ограниченный staged component114 tables: production host/каталог/стоимость/all-writer/READY ещё не приняты. [Точные остатки и выявленные ограничения](mcp-db-profile-issuer-production-map-20261101.md): штатный warmup2193constraints уже превышает старый cap2048; перенос без paging и проверки стоимости не выдаётся за готовность. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336 v5:** running; на момент 2026-11-01T04:26:00.098Z создано 8 операций, подтверждено 0/336 архивов. Новый независимый прогон использует замороженную Windows-сборку с42 миграциями и естественное истечение допуска; это не проверка будущей43 миграции, Linux или всей установки≤2GB. [Предварительный настоящий smoke](mcp-poll-capacity336-v5-smoke-acceptance-20261101.json) принят:2 операции,1 архив,8 native GET,0 ошибок обработчика, PostgreSQL остановлена и собственные ресурсы удалены. [Предыдущий отказ v4](mcp-poll-capacity336-v4-refusal-20261101.json) сохранён отдельно; его неизвестный результат не повторяется. + +**Новые промежуточные результаты:** [durable forward-charge](mcp-routing-forward-charge-source-applied-20261101.json) подключён цельным патчем16 файлов с миграцией43 после6 настоящих SQL/HTTP-групп на штатном пути миграции. Build0, manifest20, controlled migration14 и routing model9 PASS. Расширенная SQL-проверка выявила прежний пробел учёта случайных UUID миграции42: [исправлены три строго заданных seed-предиката](mcp-controlled-seed-epoch-source-applied-20261101.json), unit8/build/lint PASS, настоящий reference11 + encrypted restore/tamper PASS; происхождение UUID остаётся недоказанным, READYfalse. Общая текущая проверка миграций/restore/38 профилей/primary выполняется; прежние42 issuer-артефакты не подтверждают43/READY. Mounted artifact journey: второй запуск прошёл настоящую Next-сборку и MCP backup producer/один настоящий pg_dump, затем отказал на загрузке Next runtime config; browser/download/revoke не выполнялись, ресурсы очищены. Исправление клиентского preflight принято отдельно, исследуется config-only причина без повторного backup. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **paged TypeScript-каталог БД → durable forward-charge/общий routing ledger → mounted artifact download/revoke**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards и ограниченная выдача крупных полей журнала MCP. Последняя сборка прошла; общая финальная регрессия после последних изменений ещё обязательна. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Новый capacity336 после принятого natural-expiry smoke, затем faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [Общий routing admission](mcp-routing-common-admission-source-applied-20261101.json):4 SQL-сценария и1 настоящий MAIN HTTP/runtime PASS. Persistent MAIN/scopedfalse используют тот же порядок profile/credential/plan locks; старый план после смены профиля отклоняется, reserved2slots/debit сохранены. Правка подключена, build/lint/48 проверок PASS. Общий ledger всех writers ещё не завершён, родительский RT-3 остаётся открытым. +- [Logs lifecycle](mcp-logs-live-reset-acceptance-20261101.json):1 настоящий SQL/auth/HTTP/SSE-сценарий, два пассивных подписчика по5201 записи, actual UI merge5000. Раздельно доказаны manual response close, maintenance reset/EOF без клиентского abort, drain и новая история после gate release. Все ресурсы очищены; browser/mounted hook, миллион логов и весь UI-2 этим не приняты. +- [Подписанный профиль БД](mcp-db-protected-profile-issuer-acceptance-20261101.json):5 настоящих SQL-групп на PG18.4,42 миграции, реальный flow engine/live pair, future leaf/CAS/истечение срока/tamper/lost COMMIT ACK без повтора. Raw hashes/завершение процессов/удаление собственной БД проверены. Это ограниченный staged component114 tables: production host/каталог/стоимость/all-writer/READY ещё не приняты. [Точные остатки и выявленные ограничения](mcp-db-profile-issuer-production-map-20261101.md): штатный warmup2193constraints уже превышает старый cap2048; перенос без paging и проверки стоимости не выдаётся за готовность. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336 v5:** running; на момент 2026-11-01T04:26:00.098Z создано 8 операций, подтверждено 0/336 архивов. Новый независимый прогон использует замороженную Windows-сборку с42 миграциями и естественное истечение допуска; это не проверка будущей43 миграции, Linux или всей установки≤2GB. [Предварительный настоящий smoke](mcp-poll-capacity336-v5-smoke-acceptance-20261101.json) принят:2 операции,1 архив,8 native GET,0 ошибок обработчика, PostgreSQL остановлена и собственные ресурсы удалены. [Предыдущий отказ v4](mcp-poll-capacity336-v4-refusal-20261101.json) сохранён отдельно; его неизвестный результат не повторяется. + +**Новые промежуточные результаты:** [durable forward-charge](mcp-routing-forward-charge-source-applied-20261101.json) подключён цельным патчем16 файлов с миграцией43 после6 настоящих SQL/HTTP-групп на штатном пути миграции. Build0, manifest20, controlled migration14 и routing model9 PASS. Общая текущая проверка миграций/restore/38 профилей/primary выполняется; прежние42 issuer-артефакты не подтверждают43/READY. Mounted artifact journey: настоящая сборка Next прошла, первый запуск остановлен до fixture/browser из-за отсутствующего pg_dump рядом с embedded PostgreSQL; временные ресурсы очищены. Подготовлен preflight существующего проверенного клиента PostgreSQL без скачивания; новая проверка ждёт согласованного снимка исходников. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **paged TypeScript-каталог БД → durable forward-charge/общий routing ledger → mounted artifact download/revoke**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards и ограниченная выдача крупных полей журнала MCP. Последняя сборка прошла; общая финальная регрессия после последних изменений ещё обязательна. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Новый capacity336 после принятого natural-expiry smoke, затем faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [Общий routing admission](mcp-routing-common-admission-source-applied-20261101.json):4 SQL-сценария и1 настоящий MAIN HTTP/runtime PASS. Persistent MAIN/scopedfalse используют тот же порядок profile/credential/plan locks; старый план после смены профиля отклоняется, reserved2slots/debit сохранены. Правка подключена, build/lint/48 проверок PASS. Общий ledger всех writers ещё не завершён, родительский RT-3 остаётся открытым. +- [Logs lifecycle](mcp-logs-live-reset-acceptance-20261101.json):1 настоящий SQL/auth/HTTP/SSE-сценарий, два пассивных подписчика по5201 записи, actual UI merge5000. Раздельно доказаны manual response close, maintenance reset/EOF без клиентского abort, drain и новая история после gate release. Все ресурсы очищены; browser/mounted hook, миллион логов и весь UI-2 этим не приняты. +- [Подписанный профиль БД](mcp-db-protected-profile-issuer-acceptance-20261101.json):5 настоящих SQL-групп на PG18.4,42 миграции, реальный flow engine/live pair, future leaf/CAS/истечение срока/tamper/lost COMMIT ACK без повтора. Raw hashes/завершение процессов/удаление собственной БД проверены. Это ограниченный staged component114 tables: production host/каталог/стоимость/all-writer/READY ещё не приняты. [Точные остатки и выявленные ограничения](mcp-db-profile-issuer-production-map-20261101.md): штатный warmup2193constraints уже превышает старый cap2048; перенос без paging и проверки стоимости не выдаётся за готовность. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336 v5:** running; на момент 2026-11-01T04:26:00.098Z создано 8 операций, подтверждено 0/336 архивов. Новый независимый прогон использует замороженную Windows-сборку с42 миграциями и естественное истечение допуска; это не проверка будущей43 миграции, Linux или всей установки≤2GB. [Предварительный настоящий smoke](mcp-poll-capacity336-v5-smoke-acceptance-20261101.json) принят:2 операции,1 архив,8 native GET,0 ошибок обработчика, PostgreSQL остановлена и собственные ресурсы удалены. [Предыдущий отказ v4](mcp-poll-capacity336-v4-refusal-20261101.json) сохранён отдельно; его неизвестный результат не повторяется. + +**Новые промежуточные результаты:** durable forward-charge прошёл6 настоящих SQL/HTTP-групп на staged-коде; цельная миграция43 ещё проходит отдельную проверку. Mounted artifact journey: настоящая сборка Next прошла, первый запуск остановлен до fixture/browser из-за отсутствующего pg_dump рядом с embedded PostgreSQL; временные ресурсы очищены. Готовится исправление preflight с существующим проверенным клиентом PostgreSQL, без скачивания или изменения продукта. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **paged TypeScript-каталог БД → durable forward-charge/общий routing ledger → mounted artifact download/revoke**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards и ограниченная выдача крупных полей журнала MCP. Последняя сборка прошла; общая финальная регрессия после последних изменений ещё обязательна. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Причина отказа capacity336, затем полная вместимость, faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [Общий routing admission](mcp-routing-common-admission-source-applied-20261101.json):4 SQL-сценария и1 настоящий MAIN HTTP/runtime PASS. Persistent MAIN/scopedfalse используют тот же порядок profile/credential/plan locks; старый план после смены профиля отклоняется, reserved2slots/debit сохранены. Правка подключена, build/lint/48 проверок PASS. Общий ledger всех writers ещё не завершён, родительский RT-3 остаётся открытым. +- [Logs lifecycle](mcp-logs-live-reset-acceptance-20261101.json):1 настоящий SQL/auth/HTTP/SSE-сценарий, два пассивных подписчика по5201 записи, actual UI merge5000. Раздельно доказаны manual response close, maintenance reset/EOF без клиентского abort, drain и новая история после gate release. Все ресурсы очищены; browser/mounted hook, миллион логов и весь UI-2 этим не приняты. +- [Подписанный профиль БД](mcp-db-protected-profile-issuer-acceptance-20261101.json):5 настоящих SQL-групп на PG18.4,42 миграции, реальный flow engine/live pair, future leaf/CAS/истечение срока/tamper/lost COMMIT ACK без повтора. Raw hashes/завершение процессов/удаление собственной БД проверены. Это ограниченный staged component114 tables: production host/каталог/стоимость/all-writer/READY ещё не приняты. [Точные остатки и выявленные ограничения](mcp-db-profile-issuer-production-map-20261101.md): штатный warmup2193constraints уже превышает старый cap2048; перенос без paging и проверки стоимости не выдаётся за готовность. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336:** v4-capacity-refused-terminal; создано 189 операций, подтверждено 184/336 архивов. Отказ на операции 190; точная причина не доказана. PostgreSQL остановлена, собственные данные удалены, автоматического повтора нет. [Сохранённый отказ](mcp-poll-capacity336-v4-refusal-20261101.json). Для будущего fixture добавлено ограниченное сохранение состояния/кода до cleanup; strict types и inert checks PASS. Новый336 не запускался. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **paged TypeScript-каталог БД → durable forward-charge/общий routing ledger → mounted artifact download/revoke**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards и ограниченная выдача крупных полей журнала MCP. Последняя сборка прошла; общая финальная регрессия после последних изменений ещё обязательна. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Причина отказа capacity336, затем полная вместимость, faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [Общий routing admission](mcp-routing-common-admission-source-applied-20261101.json):4 SQL-сценария и1 настоящий MAIN HTTP/runtime PASS. Persistent MAIN/scopedfalse используют тот же порядок profile/credential/plan locks; старый план после смены профиля отклоняется, reserved2slots/debit сохранены. Правка подключена, build/lint/48 проверок PASS. Общий ledger всех writers ещё не завершён, родительский RT-3 остаётся открытым. +- [Logs lifecycle](mcp-logs-live-reset-acceptance-20261101.json):1 настоящий SQL/auth/HTTP/SSE-сценарий, два пассивных подписчика по5201 записи, actual UI merge5000. Раздельно доказаны manual response close, maintenance reset/EOF без клиентского abort, drain и новая история после gate release. Все ресурсы очищены; browser/mounted hook, миллион логов и весь UI-2 этим не приняты. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336:** v4-capacity-refused-terminal; создано 189 операций, подтверждено 184/336 архивов. Отказ на операции 190; точная причина не доказана. PostgreSQL остановлена, собственные данные удалены, автоматического повтора нет. [Сохранённый отказ](mcp-poll-capacity336-v4-refusal-20261101.json). Для будущего fixture добавлено ограниченное сохранение состояния/кода до cleanup; strict types и inert checks PASS. Новый336 не запускался. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **DB issuer → общий routing admission → настоящий logs/UI slice**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards и ограниченная выдача крупных полей журнала MCP. Последняя сборка прошла; общая финальная регрессия после последних изменений ещё обязательна. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Причина отказа capacity336, затем полная вместимость, faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [Общий routing admission](mcp-routing-common-admission-source-applied-20261101.json):4 SQL-сценария и1 настоящий MAIN HTTP/runtime PASS. Persistent MAIN/scopedfalse используют тот же порядок profile/credential/plan locks; старый план после смены профиля отклоняется, reserved2slots/debit сохранены. Правка подключена, build/lint/48 проверок PASS. Общий ledger всех writers ещё не завершён, родительский RT-3 остаётся открытым. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336:** v4-capacity-refused-terminal; создано 189 операций, подтверждено 184/336 архивов. Отказ на операции 190; точная причина не доказана. PostgreSQL остановлена, собственные данные удалены, автоматического повтора нет. [Сохранённый отказ](mcp-poll-capacity336-v4-refusal-20261101.json). Для будущего fixture добавлено ограниченное сохранение состояния/кода до cleanup; strict types и inert checks PASS. Новый336 не запускался. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **DB issuer → общий routing admission → настоящий logs/UI slice**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**Открыто 7 направлений / примерно 32 крупных пакетов. Полный план не завершён.** Это остаток результатов, не число тестов или старых чекбоксов. + +Последними этапами закрыты три пакета NATIVE: реализация VRF, локальные отказы Worker и public active NetFlow. Подключены также HTTP/STDIO terminal guards и ограниченная выдача крупных полей журнала MCP. Последняя сборка прошла; общая финальная регрессия после последних изменений ещё обязательна. + +| Направление | Пакетов осталось | Следующий необходимый результат | +| --- | ---: | --- | +| POLL | 5 | Причина отказа capacity336, затем полная вместимость, faults/co-load и Linux | +| БД | 5 | Подписанный writer-profile issuer/production host; all-writer drain, generation, restore/READY | +| Маршрутизация | 5 | Общий durable admission всех writers; controller/reserved recovery и сетевые доказательства | +| VRF/VPN/NetFlow | 5 | Реальные CHR VRF/VPN traffic, exporter interoperability и recursive native acquisition | +| Независимые пути | 4 | Третий независимый узел, двусторонний traffic/failover/VRF и cleanup | +| UI / клиенты / память | 5 | Live portal/Inspector/клиенты, настоящий UI lifecycle/большие данные и вся установка≤2GB | +| Финальная интеграция | 3 | Единый snapshot, полная SQL/HTTP/restore/native/UI-приёмка и runbooks | + +Последние подтверждённые результаты: + +- [Public active NetFlow](mcp-flow-public-active-source-applied-20261101.json):4 настоящие группы/6 REST+MCP путей, UDP→SQL,2 непустые IPC-квитанции,4 Worker ACK+exit0. ALS fix подключён; новый regression падает на старом коде, текущие22 проверки/build/lint PASS. +- [STDIO](mcp-stdio-current-catalogue-source-applied-20261101.json):4 SDK-группы и2 группы настоящего текущего моста,363 инструмента/точные схемы, обе версии протокола, длинный ID, SQL-чтение и HTTP401 после отзыва. Все5 child exits/cleanup подтверждены; build/lint/8 проверок PASS. +- [VRF/core](mcp-current-core-primary-restore-acceptance-20261101.json):68 групп VRF,38 профилей и настоящая SQL/HTTP/зашифрованная restore/tamper-приёмка предыдущего snapshot. Позднейшие Worker/STDIO/ALS изменения этим отчётом не покрыты. +- [Отказы Worker](mcp-flow-worker-startup-faults-source-applied-20261101.json):actual12, воспроизведены и исправлены2 дефекта; новые Worker ACK+exit0, поздних положительных разрешений0. + +**Capacity336:** v4-capacity-refused-terminal; создано 189 операций, подтверждено 184/336 архивов. Отказ на операции 190; точная причина не доказана. PostgreSQL остановлена, собственные данные удалены, автоматического повтора нет. [Сохранённый отказ](mcp-poll-capacity336-v4-refusal-20261101.json). Для будущего fixture добавлено ограниченное сохранение состояния/кода до cleanup; strict types и inert checks PASS. Новый336 не запускался. + +**Внешние условия:** доступны два CHR; третий независимый узел отсутствует. REST /log не доказывает полноту интервала, public autoRecovery OFF. [Linux collector10+3](mcp-linux-inventory-collector-acceptance-20261101.json) принят отдельно; whole-installation≤2GB не принят, прежний v16 показал рост swap. Новый изменяющий remote-run/private application bundle требует конкретного разрешённого scope. + +Следующая небольшая очередь: **DB issuer → общий routing admission → настоящий logs/UI slice**. Общая финальная матрица — после стабилизации исходников. [Tracker](mcp-current-progress.json) содержит точные доказательства. Ниже сохранены исходный объём и история. + +
+ +
+Предыдущая сводка — исторический snapshot; числа в ней не текущий остаток + +**1 ноября — закрыт пакет public active NetFlow.** Настройки, rebuild и purge через REST/MCP при настоящем работающем сборщике:4 группы/6 положительных путей PASS, UDP→SQL без подставных traffic rows,2 непустые коррелированные квитанции,4 Worker ACK+exit0 без forced cleanup. Исправлена потеря контекста текущего запроса на callback существующего Worker; новый регрессионный тест падает на старом коде, после подключения22 проверки/build/lint PASS. [Квитанция](mcp-flow-public-active-source-applied-20261101.json). Общая финальная матрица после последних правок ещё не выполнена. Осталось **7 направлений/~32 пакета**, в NATIVE —5. Реальные CHR traffic/exporter, вся установка≤2GB и независимый третий узел остаются открытыми. + + +**1 ноября — подключён STDIO terminal guard.** Настоящий текущий мост:2 группы PASS,363 инструмента/точные схемы, обе версии протокола и длинный ID; ограниченное SQL-чтение и настоящий HTTP401 после отзыва доступа. Все5 дочерних процессов завершены, cleanup подтверждён. Перед подключением отдельно приняты4 SDK-группы; после подключения build/lint0 и8 HTTP/Flow-проверок PASS. [Квитанция](mcp-stdio-current-catalogue-source-applied-20261101.json). Inspector/live portal и вся установка≤2GB остаются открыты. + +**Capacity336 v4 завершён отказом:**189 операций,184 архива, остановка на операции190 без ожидаемого applied. Точное конечное состояние операции не было сохранено; причина пока не доказана. PostgreSQL остановлена, собственный каталог удалён, автоматического повтора нет. [Отказ](mcp-poll-capacity336-v4-refusal-20261101.json). Остаток плана не сокращён:7 направлений/~33 пакета. + + +**Актуальный статус: открыто 7 направлений, ориентировочно 33 крупных рабочих/приёмочных пакетов. Полный план ещё не завершён.** Детальный остаток: [реестр](mcp-remaining-work-20261026.md), [машинный tracker](mcp-current-progress.json). + +Закреплённый1879-file срез прошёл37 профилей и полную SQL/HTTP-приёмку с настоящим зашифрованным restore: [2/2 PASS](mcp-final-candidate-primary-restore-acceptance-20261101.json). Последующий1881-file срез routing/NetFlow: complete, 2/2; [37 профилей/19SQL/8реальных NetFlow HTTP+SQL](mcp-owner-envelope-integration-acceptance-20261101.json). После него применён generic request owner: build и28+22 целевая проверка PASS; [реальная SQL/HTTP-приёмка8групп PASS, cleanup подтверждён](mcp-generic-inflight-current-integration-20261101.json). + +Длительная вместимость336: v4-capacity-running; проверенных архивов 184/336, созданных операций 189, наблюдение 2026-11-01T02:03:38.994Z. Естественное expiry/archive/history smoke принято отдельно. Реальный Linux collector10AF_UNIX+3projection принят: baseline четырёх контейнеров сохранён, ресурсы удалены; [отчёт](mcp-linux-inventory-collector-acceptance-20261101.json). Полная вместимость и вся установка≤2GB ещё не приняты. + +**Закрыт пакет реализации VRF:**68 SQL/HTTP-групп, полная матрица38 профилей и основная SQL/HTTP-приёмка с настоящим зашифрованным restore/tamper PASS. [Квитанция](mcp-current-core-primary-restore-acceptance-20261101.json). Между матрицей38 и primary изменён только усиленный тестовый connected-route fixture; исходники продукта идентичны, полный source set/bytes проверен независимо. + +**Закрыт пакет локальных отказов Worker:** два воспроизведённых дефекта исправлены и подключены, actual12 (8 прежних +4 новых), unit21, build/lint0. Все5 новых Worker завершились с ACK+exit0 без forced cleanup; поздних положительных разрешений0. [Квитанция](mcp-flow-worker-startup-faults-source-applied-20261101.json). После этой правки источники изменились относительно общей core-приёмки; финальная общая матрица будет выполнена после стабилизации остальных изменений. В NATIVE осталось6 пакетов реального трафика/interop/public composition; не8. + +Generic request owner:50 целевых проверок +8 настоящих SQL/HTTP PASS. В БД подключено наследование защиты новых разделов: неизменные проверенные PostgreSQL-байты, build/lint и15 MODEL-сценариев PASS; production issuer/all-writer/READY ещё открыты. SDK terminal guard подключён:5 отдельных SDK HTTP-групп +3 current catalogue/auth/audit, build/lint0 и8 целевых проверок PASS; [срез](mcp-sdk-terminal-source-applied-20261101.json). Ограничение input/result страницы обращений подключено после3 SQL/Fastify-групп и независимого review; build/lint0, исходный аудит сохранён. Доступны два CHR; независимый трёхузловой путь остаётся внешней границей. REST /log не доказывает полноту событий; public autoRecovery OFF. + +Ниже сохранены история и весь исходный нумерованный план. Исторические PASS относятся к своим snapshots и не заменяют текущие обязательные deliverables. + +
+ +
+Исторические срезы приёмки — не текущие отметки готовности + +**Текущий общий срез:2/3 этапов прошли.** Cold143 v4 natural-expiry/archive/history/restore и все37 регрессионных профилей приняты; точный набор и байты исходников совпали. Сейчас primary SQL/HTTP + restore/tamper. Открыто7 направлений/~35 пакетов; whole-installation≤2ГБ, capacity336 и внешняя/native приёмка отдельно. [Регрессия](mcp-v4-combined-regression-stage-20261031.json). + +**31 октября — следующая короткая очередь закрыта:4/4.** Cold3 одного Windows-среза; DB signature/current-head component20tests+build (без READY); общий CallToolResult budget27focused+14actual SDK HTTP/stdio synthetic-action cases+build; lifetime passthrough во всех поддерживаемых Docker launch paths (без запускаDocker). Открыто7 направлений/~35 пакетов. Сейчас идёт общая приёмка нового среза: cold archive/history/restore → все37профилей → primary/restore. [Текущий tracker](mcp-current-progress.json), [выдача MCP](mcp-generic-result-budget-acceptance-20261031.json), [DB binding](mcp-db-source-binding-acceptance-20261031.json), [Docker wiring](mcp-native-template-lifetime-deploy-wiring-20261031.json). + +**31 октября — три холодных запуска одного Windows-среза прошли.** POLL v4:143 эффекта,22 публикации/54 строки/4 GET; полный debit151 и исходные резервы сохранены. Время80.88/80.98/83.03s, максимум процесса248.91MiB при old192/semi16. Это завершённый локальный подэтап, не Linux/не вся установка≤2ГБ. Открыто7 направлений (~35 пакетов); следующие: общий MCP result delivery, operator DB binding, v4 expiry/archive/restore и capacity336. [Доказательства](mcp-poll-v4-windows-cold-stability-20261031.json). + +**31 октября — публичный v4 и первый cold143 на прежнем ограничении памяти прошли.** Настоящие REST/MCP сохраняют human approval, полный debit/reservations, crypto history и idempotency. Plain compiled Windows Node25 old192/semi16:143 операции,22 публикации/54 строки/4GET, debit151, app39883ms, пик246.95MiB; исходные180s сохранены. Это один frozen-срез, неLinux/не вся установка≤2ГБ. Следующий combined срез включает три принятых параллельных малых блока: DB17SQL source-capture regression, RT3.B1 контракт15tests, N5a template lifetime+owned flow. Открыты7 направлений, ориентировочно35 acceptance/work-пакетов; части общих прогонов пересекаются. [Факты POLL](mcp-poll-v4-public-cold-acceptance-20261031.json). + +**30 октября — приватный durable POLL v4 реализован и проверен на настоящей БД.** Полные143 операции исполняются18 DB-группами≤8; logical mutation cost151, полный резерв4019 rows/8421376 bytes сохранены, journal maxStages26. После настоящего20с ожидания подтверждены20 публикаций/50 строк журнала/4localhost GET, отдельный свежий старт, crypto history и чтение новым экземпляром. Три группы с корректными MAC, но неверным offset/усечением/одиночным эффектом отвергнуты. Management/build/lint прошли;42 миграции побайтно прежние. Публичный issuer остаётсяv3; terminal/archive v4, настоящий MCP debit, public cold143/capacity336/общая текущая регрессия/Linux/вся установка≤2ГБ и все7 направлений остаются открыты. [Приватная приёмка](mcp-poll-v4-private-durable-acceptance-20261030.json). + +**30 октября — исправлено сравнение native JSON после записи в JSONB.** Перестановка ключей PostgreSQL больше не отклоняет неизменный snapshot; значения, типы, неизвестные поля и порядок массивов проверяются строго. Полный management POLL, настоящий зарегистрированный REST/MCP с намеренно переставленными ключами, подписанная история/idempotency, build и lint прошли. Все42 миграции побайтно прежние. До правки полный frozen v3/37/primary/archive/restore прошёл; текущий полный повтор ещё требуется. Изолированный v4 с тем же исправлением выполнил143 операции в18 группах за59078ms,20 публикаций/4GET; public registry/admission/durable journal ещё не подключены, performance/cold143 public/вся установка≤2ГБ не приняты. [Точная приёмка](mcp-poll-jsonb-snapshot-acceptance-20261030.json). Все7 направлений остаются открыты. + +**30 октября — полный локальный frozen v3 принят.** Последовательно прошли настоящая естественная expiry/archive/history/restore, все37 профилей с `/logs`, primary SQL/HTTP и encrypted pg_dump/pg_restore/tamper. Проверены точный набор1678 исходников и побайтная неизменность. Это отдельные свежие одноразовые БД в одном последовательном запуске. Кандидат атомарного обслуживания прошёл8 component и4 настоящих SQL-сценария, но ещё не включён в MCP. Cold143/capacity336/текущий Linux/вся установка≤2ГБ и все7 направлений остаются открыты. [Полная локальная приёмка](mcp-poll-v3-full-local-acceptance-20261030.json). + +**30 октября — настоящий архив v3 и восстановление прошли.** После естественного истечения исходного допуска штатный worker сохранил21 публикацию/52 исходные строки журнала в проверенный архив. Текущий владелец с новым допуском читает историю через global reconcile; прежний отозванный credential, суженный grant и чужой владелец получают отказ без нового native-вызова или расхода квоты. Настоящий pg_dump/pg_restore и отказ повреждённому архиву прошли. Frozen v5 продолжает37 профилей и primary/restore; cold143/capacity336/Linux/вся установка≤2ГБ и все7 направлений остаются открыты. [Подтверждённый результат](mcp-poll-v3-archive-history-acceptance-20261030.json). + +**30 октября — исправлен архивный источник v3 после настоящего отказа по окончании исходного допуска.** Старый terminal verifier принимал только1/2 и сравнивал весь расшифрованный blob с прежним context, поэтому worker безопасно не создавал архив v3. Теперь он отдельно аутентифицирует executionStart через общий anchor verifier, сохраняя исходный contextProof. Прошли64 focused terminal/archive/compactor проверки, backend build и фактическая компиляция исправленного sizing entry. Новый frozen v5 выполняет повторную естественную expiry/archive/history/restore, затем37 профилей и primary/restore. Диагностический compiled cold подтвердил174265 SQL calls при4 native reads без полного receipt; приёмкой это не является. Старые сроки/квоты не расширены. [Прогресс](mcp-poll-v3-terminal-and-diagnostic-progress-20261030.json), [измерения и непринятый кандидат оптимизации](mcp-poll-cold-optimization-review-20261030.md). Все7 направлений, cold143/capacity336/Linux/вся установка≤2ГБ остаются открыты. + +**30 октября — production POLL v3 допускает ожидание человеческого подтверждения без потери строгих in-flight проверок.** Каталог защиты и настройки читаются одним доверенным SQL-снимком; отдельный approvalProof отличает статистику от конфигурации и ABA. Свежий полный старт подписывается в существующем encrypted anchor, исходные plan/contextProof, квота и сроки сохраняются. Новые journal/history readers аутентифицируют этот старт; V1/V2 читаются как прежде. Подтверждены настоящий REST/MCP,20с ожидания, отказ ABA/disabled guard до admission/native,31 journal и22 history/snapshot компонентных случая, management/build/lint. Текущая собранная версия с настоящими коллекторами/NetFlow прошла20с ожидание:11 544 datagrams, точные17 316 000 bytes/34 632 packets,188 чтений без ошибок,21 publication/52 rows. Холодные143 не приняты: два прогона завершились без receipt в прежнем сроке; отдельная обязательная команда сохранена. Архивный тест после штатного bootstrap и следующие37 профилей/primary/restore выполняются на замороженных исходниках. Linux/capacity336/вся установка≤2ГБ и все7 направлений остаются открыты. [Приёмка](mcp-poll-delayed-approval-acceptance-20261030.json), [протокол](mcp-poll-delayed-approval-design-20261030.md). + +**30 октября — версия настроек включена в штатную миграцию0042 и прошла настоящее восстановление.** Все прежние41 SQL-файла сохранены побайтно. Переход с непустой41, повторный запуск и отказ при частично совпадающей unmarked-схеме сохраняют исходные данные и атомарность DDL/маркера. Реальные pg_dump/pg_restore сохранили полную логическую схему, данные и UUID; восстановленная защита обнаруживает A→B→A. Прошли32 SQL-группы защиты, пять сценариев миграции/restore,19 manifest-сценариев,13 controlled-history тестов, девять настоящих MCP-сценариев настроек, штатные REST/MCP full poll и сборка/lint. Inner poll остаётся v2; свежее начало исполнения после ожидания ещё требуется связать с исходным plan и неизменяемым журналом, включая историческую/архивную проверку. Полная текущая матрица/primary/restore, Linux и вся установка≤2ГБ не приняты; все7 направлений открыты. [Приёмка](mcp-poll-epoch-numbered-acceptance-20261030.json), [интеграция](mcp-poll-delayed-approval-design-20261030.md). + +**30 октября — подготовлена изолированная основа подтверждения после ожидания.** Версия конфигурации трёх групп настроек сохраняется при обновлении статистики и меняется при A→B→A, неизвестных новых полях и повторной вставке. Фиксированная проверка каталога отказывает при подмене/отключении защиты. Прошли 32 настоящие SQL-группы с полным откатом своих DDL/данных, пять компонентных тестов, TypeScript и scoped lint. Все 41 прежняя миграция и штатные executor/journal/actions сохранены побайтно. Предложение не зарегистрировано как миграция; пользовательский сценарий ожидания ещё не принят. Следующий шаг — связать свежий полный снимок после подтверждения с неизменяемым журналом и исторической проверкой, затем actual restore и общая матрица. Все семь направлений и вся установка ≤2 ГБ остаются открыты. [Приёмка](mcp-poll-settings-epoch-acceptance-20261030.json), [точки интеграции](mcp-poll-delayed-approval-design-20261030.md). + +**30 октября — штатный REST poll подключён к общему исполнителю MCP.** `POST /api/servers/:id/poll` использует durable-журнал и исходный проверенный HTTP-контекст вместо legacy `pollServer`. JWT/local режим, владелец, права и идентификатор берутся из настоящих auth hooks; подмена JSON/header/authUser не даёт доступа. Исходное монотонное окно и отключение клиента проверяются на границах metadata I/O. Серверный предел 180 секунд сохранён; клиент ждёт до 185 секунд для получения ответа. Массовый опрос последовательный, без автоматического retry. Прошли 25 компонентных сценариев, 5 JWT + 3 local HTTP-проверки, штатный TCP/Fastify/SQL REST-опрос (200, 4 GET, отказы 400/401/403/404 без native), обычный MCP-опрос (4 GET, 21 публикация, 52 строки) и браузерный batch-тест. Сборка и lint прошли, временные Next-конфиги восстановлены. Полные 37 профилей и primary/restore после этих продуктовых изменений ещё не повторены; предыдущий полный PASS ниже относится к своему снимку. Следующий пункт POLL — ожидание человеческого подтверждения с сохранением защиты от ABA. Весь план и ≤2 ГБ остаются открыты. [Отчёт](mcp-poll-rest-route-acceptance-20261030.json). + +**30 октября — текущая функциональная приёмка завершена.** Все 37 профилей с шестью новыми тестами наблюдателя, основная SQL/HTTP-приёмка и настоящее восстановление дампа прошли; повреждённый архив отклонён до замены данных. Оба этапа выполнены на одном замороженном снимке, все 1665 контрольных сумм совпали. Ранее падавший общий routing-контекст прошёл полный сценарий после переноса тестового наблюдателя за настоящий COMMIT, вне HTTP-ответа. Проверки прав, исходные тайм-ауты и запрет replay сохранены. Совместная работа четырёх сборщиков и MCP принята локально; Linux текущего изменения, constrained cold143/capacity336, вся установка ≤2 ГБ, задержанное подтверждение и переключение legacy REST poll ещё открыты. Все семь направлений полного плана остаются незавершёнными; public autoRecovery выключен. [Итог текущего блока](mcp-collector-checkpoint-final-acceptance-20261030.json). + +**30 октября — устранена чувствительность scoped fixture к тайм-ауту ping.** Дополнительное чтение общего контекста перенесено из HTTP-ответа после настоящего COMMIT перед контрольным ping. Наблюдатель не наследует контекст записи и не подменяет драйверные ACK. С одинаковой задержкой 11 секунд старое место дало `unknown`, новое — `applied` при прежнем тайм-ауте 10 секунд; исходный исправленный fixture также прошёл узкое SQL/HTTP-воспроизведение. Шесть тестов COMMIT/ROLLBACK/lost ACK включены в обязательный профиль маршрутизации; строгие типы и lint прошли. Новый замороженный прогон 37 профилей и последующей основной приёмки с восстановлением БД выполняется. Продуктовые исходники, migrations и сроки допусков этой правкой не менялись. [Отчёт проверки](mcp-checkpoint-observer-acceptance-20261030.json). + +**30 октября — основная приёмка остановилась на общем routing-контексте.** На строке 673 scoped fixture получен `unknown` вместо `applied`; исходники до/после совпали, заключительное восстановление БД не достигнуто. Все 37 профилей текущего снимка остаются пройденными. Узкое изолированное воспроизведение прошло, но дополнительная проверка внутри HTTP-ответа заняла около 9,96 секунды при тайм-ауте ping 10 секунд. Влияние места этой тестовой задержки проверяется отдельно; исходная причина полного отказа пока не объявляется доказанной. Продуктовый код, guards и сроки допуска не меняются. [Фактический отказ](mcp-collector-shared-primary-failure-20261030.json). + +**30 октября — после исправления блокировок прошли все 37 профилей.** Один устаревший тест расчёта трафика адаптирован к выделенному внутреннему телу сборщика; продуктовый код при этом не менялся. Точечный профиль с настоящей БД и полный повтор 37 профилей завершились с exit 0, все 1663 контрольные суммы сохранились. Отдельные 28 SQL-сценариев записи сборщиков и изолированный публичный MCP/REST-host poll также прошли. Основная SQL/HTTP-приёмка с настоящим dump/restore текущего снимка запущена отдельно и пока не принята. Вся установка ≤2 ГБ, задержанное подтверждение и все семь направлений остаются открыты. [Отчёт матрицы](mcp-collector-shared-regression-acceptance-20261030.json). + +**30 октября — совместная работа backend и сборщиков проверена.** Четыре полных пути сбора получили разделяемую PostgreSQL-блокировку приложения, совместимую между сборщиками и исключающую изменение конфигурации. Настоящие scheduler/UDP worker/HTTP/MCP: 11 106 датаграмм, точные 16 659 000 байт / 33 318 пакетов, 150 чтений без ошибок, 21 публикация poll / 52 строки журнала. Отдельная задержка подтверждения 20 секунд с обновлением статистики безопасно отклонена до native MCP и его записей исполнения. Удобство задержанного подтверждения остаётся открытым; proof и запрет автоматического повтора сохранены. Текущие 37 профилей и primary/restore после изменения блокировок ещё проверяются. Предыдущие PASS ниже относятся к своим снимкам. Вся установка ≤2 ГБ и все семь направлений не завершены. [Область проверки](mcp-collector-coload-20261030.md), [фактический отчёт](mcp-collector-coload-acceptance-20261030.json). + +**30 октября — текущий Linux-прогон v16 завершён.** Обычный опрос Node22 прошёл за86047ms:21публикация/52строки/4GET, пик процесса MCP≈248,85MiB при old192/semi16 и собственных Node320/PG192MiB. Общая приёмка ресурсов **не пройдена**: выросли счётчики подкачки; OOM не было. Собственные ресурсы и каталог удалены, исходные сервисы неизменны. Единственное разрешение v16 израсходовано. Cold143/capacity336 и вся установка≤2ГБ остаются открыты. [Результат](mcp-poll-policy-loan-linux-20261030.json). + + +**30 октября — POLL использует исходную read-only транзакцию.** Функциональная приёмка текущего снимка прошла:32unit,15SQL-групп,7public afterIO-отказов, management/types/build/lint и все37профилей с39 обязательными API/SQL-проверками. Обычный опрос: 36350ms; финальный cold143 **не принят**: 180766ms/publish.141, квитанции нет. Исходные180s и old192/semi16 сохранены. Это WindowsNode25; Linux, стабильность cold, capacity336, полная crash/retention-матрица и вся установка≤2ГБ открыты. Основная SQL/HTTP-приёмка и настоящий pg_dump/restore/tamper текущего снимка также прошли; исходники совпали до/после. Запуски primary/restore и37профилей раздельные. [Primary](mcp-poll-policy-loan-primary-acceptance-20261030.json). Все7 направлений открыты; public autoRecovery выключен. [Отчёт](mcp-poll-policy-loan-acceptance-20261030.json). + + +**30 октября — полный набор37 профилей прошёл.** После девяти исправлений независимых тестовых подстановок текущий набор завершился с exit0; все1668 контрольные суммы совпали до/после. SQL/HTTP primary и настоящий dump/restore/tamper ранее также прошли: продуктовый код и его зависимости не менялись; исправленные unit fixtures не входят в статический граф этих двух entry. Запуски primary/restore и37 профилей были раздельными. Холодный poll143, capacity336, вся установка≤2ГБ и все7 широких направлений остаются открыты. REST `/log` — единственный источник, public autoRecovery выключен. [Приёмка](mcp-policy-regression-acceptance-20261030.json). + + +**30 октября — общая регрессия текущего снимка завершена.** SQL/HTTP primary, настоящий pg_dump/pg_restore и отказ при повреждении архива прошли после последних изменений POLL. Все1603 контрольные суммы исходников совпали до/после прогона. Полный одновременный набор37 профилей, cold143/capacity336 и вся установка≤2ГБ остаются открыты; все7 направлений не закрыты. REST `/log` остаётся единственным источником, public autoRecovery выключен. [Результат](mcp-policy-primary-acceptance-20261030.json). + + +**29 октября — согласованное чтение политики POLL проверено.** Защиты, счётчики и временный доступ читаются одним свежим ограниченным SELECT вместо трёх на прежнем соединении. Исходный допуск до/после I/O, проверки каждого SQL/DDL и180s сохранены.4unit,14SQL групп,7public afterIO, management/build/lint прошли. Штатный запуск: Windows48849ms/Linux82374ms; полный receipt21/52/4GET. Cold181516ms/pub102 не принят; ускорение не доказано. Linux Node exit0, но общий gate памяти отказал из-за роста host swap; свои ресурсы удалены, четыре сервиса неизменны. Все7 направлений, вся установка≤2ГБ и общий current primary/restore остаются открыты. REST `/log` — единственный источник, полнота unknown, public autoRecovery выключен. [Отчёт](mcp-poll-policy-source-acceptance-20261029.json). + + + +**29 октября — штатный bootstrap подтверждён на Linux.** Собранный MCP после настоящего `initDatabase` получил полный receipt за87289ms:17 maintenance/21 publication/52 rows/4GET, Node22, old192/semi16, processpeak≈250.3MiB. Собственные Node320/PG192 MiB ресурсы удалены, четыре прежних сервиса неизменны. Host swap counters выросли; вся установка≤2ГБ не принята. Повторный Windows cold с восстановленным runtime также отказал за181245ms наpublish110: прежние113s не доказывают повторяемость. [Раздельные результаты](mcp-poll-normal-startup-acceptance-20261029.json). + + +**29 октября — штатный bootstrap проверяется отдельно.** Новый постоянный `test:mcp:poll-memory-bootstrap:pg` вызывает настоящий `initDatabase` до API: WindowsNode25 прошёл за42737ms,17 maintenance/21 publication/52 rows/4GET, processpeak≈241.2MiB. Linux этого профиля пока не принят. Cold143 и вся установка≤2ГБ остаются открыты. Объединение journal validation отклонено после actual181403ms failure; runtime восстановлен побайтно, дополнительные7SQL групп с12 отказами и наблюдением двух блокировок сохранены. [Bootstrap](mcp-poll-normal-startup-acceptance-20261029.json), [отклонённый кандидат](mcp-poll-owned-validation-review-20261029.json). + + +**29 октября — текущий Linux cold POLL: не принят.** С фиксированным protocol statement и без SQL-observer измерено181456ms при прежнем180s deadline. Успешная полная квитанция не подтверждена. Memory/co-load gate: не принят; вся установка≤2ГБ остаётся открыта. Собственные ресурсы удалены, четыре прежних сервиса неизменны. [Отчёт](mcp-bounded-poll-linux-20261029.json). + + +**29 октября — фиксированный SELECT прав подготовлен в ограниченном connector.** Текст SQL не изменился; каждое выполнение заново читает данные и связывает текущие параметры.47 тестов,13 actualSQL групп,7 public afterIO отказов, management/build/lint прошли. Настоящее изменение схемы вызывает отказ и уничтожение соединения без повтора. Один текущий Windows cold143 завершился за113202ms,147 публикаций/304 строки/4GET; processpeak≈248.9MiB при old192/semi16. Linux, повторяемость новой сборки, общая primary/restore и вся установка≤2ГБ остаются открыты. [Отчёт](mcp-bounded-principal-plan-acceptance-20261029.json). + + +**29 октября — Linux POLL пока не принят.** Полный опрос Node22 на временных контейнерах Node320/PG192 MiB завершился UNKNOWN по исходному180s deadline. Диагностика показала более20тыс. свежих principal SELECT до38-й публикации; CPU-throttling PostgreSQL не наблюдался, Node после exit не измерим этим способом. На хосте выросли счётчики swap, поэтому RAM/no-swap gate также не пройден. Четыре прежних сервиса неизменны, собственные ресурсы удалены. Это снимок ДО последующей проверки protocol preparation, а не результат новой оптимизации. [Данные](mcp-poll-linux-diagnostic-20261029.json). + + +**Cold143 Windows, итог серии:**3/3 predefined current compiled runs без SQL-observer прошли136.8–148.9s в180s, max processRSS≈252.2MiB; scope143maintenance/147publications/304rows/4GET неизменен. [Отчёт](mcp-poll-local-cold-stability-20261029.json). TargetLinuxNode22/capacity336/whole2ГБ и финальный общий повтор после последней auth-оптимизации ещё открыты. + +**Последняя правка POLL.C1a:** current compiled cold143 без SQL-observer прошёл137.5s при old192/semi16 и peak≈250MiB. Сохранены original deadline/grant и второй полный source check после I/O;49 component,10actualSQL,7public races и management PASS. Повторяемость проверяется отдельно; target Linux/Node22,capacity336/whole2ГБ открыты. Общий primary ниже принят до этой последней правки и не выдаётся за её полный повтор. [Отчёт](mcp-poll-conditional-progress-20261029.json). + +**Общий текущий primary/restore принят29 октября:** SQL/HTTP, настоящий dump/restore и tamper завершены exit0;1596 pins неизменны. Два тестовых ожидания согласованы с bounded preflight и сохранением CAS history; production guards не ослаблены. Принят стандартный runtime; constrained, seeded-rich и UI имеют отдельные отчёты. Это не единый запуск37 профилей, не native/livePortal и не вся установка ≤2ГБ. [Приёмка](mcp-current-combined-primary-acceptance-20261029.json). Дальше — холодный POLL; осталось **7 направлений**. + +**UI.A,29 октября:** добавлен постоянный auth-enabled browser-набор `npm run test:mcp:auth-ui`:3/3 Chrome-теста и lint прошли; проверены выбор прав/серверов, маскирование токена, pause/revoke/monitor/stop и read-only ограничения. API/JWT синтетические; live Portal/Sender/UI и вся установка ≤2ГБ этим не закрываются. [Отчёт](mcp-auth-ui-acceptance-20261029.json). Далее задачи выполняются последовательно без субагентов по последнему указанию пользователя. + +**Приёмка внешнего процесса:** добавлены три постоянных test-only файла и выполнены13 реальных локальных SDK/HTTP/stdIO сценариев, включая lifecycle токенов и PKCE. Portal JWT/consent синтетические; live Portal/Sender/UI, общий предел всех результатов и весь EXT2 этим не закрываются. [Отчёт](mcp-external-process-acceptance-20261029.json). + +**Последний срез29 октября:** logical-schema-v3 принят на seeded SQL с настоящим dump/restore/tamper и подписанными reference; all-writer/source-generation/READY ещё открыты. Ограниченный профиль NetFlow применён: два читателя UI/MCP,2 MiB/query и4 MiB/source-response,2 MiB/encoded-response, отказ до декодирования больших данных. Холодные SQL-проверки обоих профилей и три затронутые регрессии прошли. Compose2gb выбирает этот режим opt-in вместе с old192/semi16, без применения к серверу. Локальные470.46 MiB app+worker не подтверждают всю установку ≤2ГБ на Linux. Текущий общий primary/restore выполняется. Пользователь подтвердил только REST `/log`; полнота событий неизвестна, поэтому autoRecovery выключен. [Схема](mcp-logical-schema-v3-acceptance-20261029.json), [профиль памяти](mcp-constrained-flow-profile-acceptance-20261029.json). Осталось **7 направлений**. + +**Маршрутизация и БД,29 октября — новый срез:** фиксация исходного native-состояния при человеческом включении профиля подключена. Общая проверка перед записью сверяет исходные ID/candidate/target/dependency/source; собственное MAIN-переключение обновляет baseline только по подписанной квитанции живого execute. MAIN и все5 scoped forward rounds прошли; штатный scoped/inverse набор завершился exit0, включая8 сценариев inverse и17 persistent-authority проверок. Пересекающиеся активные профили отклоняются до CAS; настоящая гонка двух заранее одобренных включений подтвердила STATE_CONFLICT, отсутствие второго профиля и0nativewrites. [Отчёт](mcp-routing-baseline-progress-20261029.json). SQLite marker/sequence включены в существующий writer barrier; реальный ответ `ROLLBACK` больше не подтверждает COMMIT.22 SQL-проверки текущих helpers и постоянные10 focused SQL-сценариев прошли; all-writer/source-generation/READY остаются открыты. [Отчёт БД](mcp-database-import-control-acceptance-v1.json). Полный primary/restore после этого среза ещё не выполнен; всего осталось **7 направлений**. + +**Локальный замер NetFlow:**4 владельца HTTP одновременно удерживали8 SQL-источников почти по16MiB; все8 MCP-вызовов успешны, настоящий Worker держал50000 pending flows/2048 rings. Пик RSS приложения вместе с тестовыми клиентами и Worker —757,94MiB. Это Windows/Node25 и отдельный зафиксированный снимок до последних routing/COMMIT-изменений, с main/effective Worker old-space512MiB; не кандидат192MiB из Compose и не вся установка. Общая приёмка ≤2ГБ открыта. [Границы и данные замера](mcp-netflow-memory-progress-20261029.json). + +**NetFlow,29 октября — текущие исходники:** применены ограничения агрегатов до изменения состояния, атомарные SQL-квитанции качества/потерь, проверка настоящего ответа COMMIT и общий лимит чтения MCP/REST/SSE.18 компонентных,8 Worker/UDP/SQL и4 quality SQL-сценария прошли; постоянные7 ownership-проверок, реальные SQL/HTTP MCP/REST и SQL source-boundary проверки, полный traffic-flow с SQL `/logs`, build и scoped lint прошли. Незавершённый временный observer сохранён отдельно; постоянный HTTP runner подтвердил exit0 и cleanup.128MiB на владельца — предварительный резерв, физический пик и вся установка ≤2ГБ ещё не приняты. REST `/log` не подтверждает полноту интервала, public autoRecovery выключен. [Отчёт](mcp-native-read-bounds-progress-20261029.json). Осталось **7 направлений**. + +**Последний primary/restore,29 октября — до новых ограничений NetFlow:** основной SQL/HTTP-прогон, настоящий dump/restore и отказ подменённому архиву прошли;1621 файл исходников/миграций/скриптов того снимка не изменён во время приёмки. [Приёмка](mcp-primary-v2-heartbeat-acceptance-20261029.json). Это не единый повтор37 профилей и не RAM/CHR-приёмка; после текущих изменений общий прогон нужно повторить. + +**Дополнительная проверка NetFlow scope:** общие счётчики потерь теперь доступны только при `allowGlobal` и существующем разрешении `netflow.settings.read`. Для остальных подключений качество явно недоступно; данные выбранного сервера и смысл пагинации сохраняются.10 компонентных проверок,70 вызовов семи настоящих зарегистрированных read handlers с изолированными source/auth adapters, реальная SQL/HTTP-пара scoped/global, полный traffic-flow и профиль system-database прошли. Постоянный HTTP runner после явного завершения esbuild подтвердил exit0 и cleanup. + +**Heartbeat коллектора,29 октября:** применён протокол одного неподтверждённого снимка с объединением обновлений и ACK после обработки.32 точечных и8 настоящих Worker/UDP/SQL-сценариев, весь traffic-flow, SQL `/logs`, TypeScript, ESLint и backend build прошли. Новый сценарий удерживает ACK на два интервала таймера настоящего Worker, отказывает чужому ACK и возобновляет свежий снимок по правильному;18600байт/78пакетов сохранены. Основная SQL/HTTP-интеграция и настоящий dump/restore/tamper прошли на том же снимке; ограничение side aggregates/read snapshots и вся установка ≤2ГБ этим не закрыты. [Отчёт](mcp-heartbeat-backpressure-progress-20261029.json). + +**Дополнение к текущему срезу:** настоящий worker подтвердил гонку ручного scoped fixture; весь изолированный сценарий прошёл после изменения только тестовой координации. Последний cold POLL с144 maintenance-командами не получил квитанцию за181678ms, остановившись на публикации81; аварийная точка архива не достигнута. Ранний успешный локальный прогон151s сохранён, но не доказывает стабильную cold-производительность. Применён внутренний planner V2, исключающий только доказанные NOOP; старые подписанные V1-планы исполняются прежним planner без переиздания. Постоянные проверки V1/V2, полный профиль server poll, TypeScript, ESLint и compiled build прошли. Настоящий тёплый MCP poll прошёл за49,6s:17 действий/21 публикация/52 строки. После естественного истечения допуска авария дочернего процесса и точного собственного PG backend до COMMIT откатила архив/удаление/refund/audit, сохранила полный журнал, charge и отдельно зафиксированный claim; новый worker не повторил попытку. Исходники/compiled pins неизменны, собственная PG остановлена и удалена. [Отчёт V2](mcp-poll-v2-progress-20261029.json). Этот warm-сценарий не заменяет cold-производительность. Основная SQL/HTTP-интеграция и настоящий dump/restore/tamper прошли на текущем снимке. Cold-производительность и2ГБ остаются открытыми. Осталось **7 направлений**. + +**Последняя проверка 29 октября:** разделение IPFIX/NetFlow-шаблонов прошло37 точечных проверок и7 настоящих Worker/UDP/SQL-сценариев: два порта отправителя, два домена, точное сохранение18600байт/78пакетов. Полный профиль трафика также пройден.37 backend-профилей покрыты первоначальным пакетом и повтором исправленного тестового SQL-стаба; единый чистый прогон этим не заявляется. Для primary-интеграции воспроизведено слишком короткое ожидание: тот же NetFlow settings стал applied через3,16s после timeout наблюдателя через3,15s. Ограничено только ожидание теста по монотонным часам; production timeout/TTL не изменены. Повтор primary с настоящим dump/restore/tamper прошёл на текущем снимке. [Отчёт](mcp-db-native-progress-20261029.json). Осталось **7 направлений**. + +**Состояние на 29 октября:** speed-test 3/3 и manual collectors 4/4 завершены; REST: 200 mediated / 62 excluded / 0 pending. Весь план: **осталось 7 направлений**. Приняты локальные NetFlow worker/services и компоненты routing forward rounds/private handoff. Полный autoRecovery, внешняя сетевая приёмка и нагрузка ≤2 ГБ остаются открыты. [Текущий реестр](mcp-current-progress.json), [текущая приёмка POLL](mcp-poll-terminal-storage-acceptance-20261029.json). + +**Текущий этап POLL:** numbered41, четыре настоящих MCP poll с естественным истечением срока, обе границы потери COMMIT ACK и чтение архива через текущий доступ прошли SQL/restore. Штатный worker архивирует завершённый журнал; потеря ACK фиксации claim оставляет полный источник и его полный расход квоты без повторного выполнения. Полная backend-регрессия на41 миграции прошла37 профилей. Повтор fresh-storage без диагностического observer также прошёл; причина прежнего единичного UNKNOWN не установлена, исходный отказ сохранён. Открыты оставшаяся crash/unknown/retention матрица, настоящие336 источников и нагрузка всей установки ≤2 ГБ. Остаток всего плана — **7 направлений**, подробные открытые gates — в [рабочем списке](mcp-remaining-work-20261026.md). + +
+ +## 1. Цель, архитектура и границы + +**Ранее принят отдельный локальный POLL; текущая cold-производительность открыта:** [текущий отчёт оптимизации](mcp-poll-cost-reduction-progress-20261029.json) фиксирует полный compiled poll без диагностического observer: 144 maintenance, 148 публикаций, 306 строк журнала, около151s и peak RSS252 MiB при исходном deadline180s и heap240 MiB. Согласованное чтение principal прошло10 настоящих SQL-групп, metadata/journal —4. Сохранены прежние UNKNOWN на публикациях97 и115; их причины не объявляются доказанными. Результат получен на Windows/Node25 с отключённым scheduler и не закрывает Linux/Node22, совместную нагрузку сборщиков или всю установку ≤2 ГБ. Первый полный backend-прогон остановился на коротком наблюдении NetFlow settings; отдельный повтор подтвердил позднее applied того же operation. Отказ сохранён в tmp/mcp-current-full-backend-20261029.log. Позднее исправлен только тестовый срок controller grant: он теперь ограничен остатком исходных прав. Текущий основной SQL/HTTP-прогон и настоящий dump/restore/tamper прошли;37 профилей на одном новом снимке этим не заявляются. + +**Уточнение источника логов от пользователя, 29 октября:** доступен только текущий RouterOS REST `/log`, внешнего collector нет. Используем этот источник для просмотра, поиска и диагностики. Ограниченный ring buffer, его `.id` и временные метки не доказывают непрерывный интервал событий или отсутствие перезапуска. Текущий native parser поэтому возвращает `authenticated-log-interval-coverage-unsupported` даже для корректного непустого ответа. AutoRecovery, которому требуется такое доказательство, остаётся запрещённым при неизвестной полноте; эти сценарии не считаются принятыми. Отдельный collector не разворачивается без запроса пользователя. + +Предоставить нейросетям управляемый доступ ко всем разделам MikrotikManager. Реализовать самостоятельный web-раздел MCP, полноценное управление интерфейсами и серверный Route AI с профилями Community, IPv4/IPv6, OSPF, ECMP, RPF и VRF. + +```mermaid +flowchart TD + Clients["AI-клиенты: Streamable HTTP / stdio-мост"] --> MCP["Backend /mcp"] + Web["Web-раздел /mcp"] --> Admin["Backend /api/mcp/*"] + MCP --> Policy["Политики, лимиты и защищённые объекты"] + Admin --> Policy + Controller["Серверный Route AI"] --> Policy + Policy --> Plans["Планы, подтверждения, блокировки и аудит"] + Plans --> Services["Общие сервисы приложения"] + Services --> RouterOS + Services --> PostgreSQL + RouterOS --> Observations["Метрики, конфигурация и /logs"] + Observations --> Controller +``` + +- MCP встраивается в существующий Fastify backend. Основной endpoint — `/mcp`, транспорт — Streamable HTTP через HTTPS. +- Локальный stdio-мост использует тот же endpoint и политики; не получает прямой доступ к БД и учётным данным роутеров. Диагностические сообщения моста идут в stderr, stdout предназначен для MCP. +- Frontend `/mcp` — отдельный раздел основного меню. Административный API — `/api/mcp/*`. Frontend и backend сохраняют текущую архитектуру отдельных адресов. +- Использовать официальный TypeScript SDK, зафиксировать версии и проверить совместимость протоколов `2026-07-28` и `2025-11-25`. SDK выполняет согласование версии; собственный протокол поверх JSON-RPC не изобретать. +- Общие контракты — Zod в `@mmapp/contracts/mcp`. Из REST-обработчиков выделяются сервисы, общие для UI, MCP и Route AI. Обращений MCP к собственному REST API по HTTP нет. +- Создать реестр покрытия: каждая существующая операция приложения получает MCP-инструмент либо явную причину исключения, если это управление самим MCP, аккаунтами портала или локальными аккаунтами RouterOS. Новый REST endpoint должен попадать в проверку покрытия. +- Для каждой операции реестр определяет схему, версию, права, область объектов, риск, лимиты, защищённые зависимости, обработчик, проверку результата и возможность восстановления. Неизвестная операция запрещена. +- Не предоставлять произвольные SQL, HTTP-запросы, RouterOS-скрипты или универсальный `exec`. Существующие разрешённые команды диагностики представить типизированными инструментами. + +### Границы пользователей и внешних приложений + +- Управление пользователями включает `/users` MikrotikManager. Аккаунты портала входа и локальные пользователи/группы RouterOS исключены. +- Настройки ролей и прав в `/users` не подменяют разрешения JWT портала и не превращаются автоматически в источник авторизации backend. +- EvoBGP используется через существующую интеграцию каталога. Управление самим внешним EvoBGP не включать. +- Неподдерживаемые свойства RouterOS явно отражаются в capabilities. Не имитировать поддержку и не применять неизвестные значения по умолчанию. + +## 2. Подключения, авторизация и политики + +### 2.1. Три режима и опасные действия + +| Режим | Поведение | +| --- | --- | +| Наблюдатель | Чтение и ограниченная ненагрузочная диагностика | +| С подтверждением | Чтение и подготовка планов; применение после одобрения человеком в UI | +| Автономный | Применение действий в рамках политики подключения | + +Для опасных действий поддержать обе согласованные политики: обязательное подтверждение либо автономное выполнение по явному административному разрешению. По умолчанию — подтверждение. + +Опасными считать удаление данных/объектов, восстановление backup/БД, изменение прав, управления устройствами, firewall, маршрутизации, интерфейсов, туннелей, интеграций и запускаемой нагрузки. Класс действия задаётся сервером, а не моделью. + +Эффективный доступ — пересечение прав владельца, подключения, разрешённых операций, серверов, режима, срока, лимитов и защит объектов. Глобальные действия требуют отдельного допуска. Список серверов задаётся явными ID; новые серверы автоматически не включаются. + +`tools/list` фильтруется по разрешениям, но каждый вызов независимо проходит полный контроль. Запрещённые поля и зависимости нельзя скрыть внутри разрешённой операции. + +### 2.2. Авторизация + +- Именные Bearer-токены: владелец, срок, назначение, область доступа; хранить криптографические хеши. Полное значение показывать один раз. Ротация создаёт новый токен и отзывает старый. +- OAuth 2.1: Authorization Code + PKCE, discovery, resource/audience, точные redirect URI, ротация refresh-токенов и отзыв согласий. Первый релиз использует предварительно зарегистрированных OAuth-клиентов. +- Отдельный OAuth-модуль backend использует портал для подтверждения личности и выдаёт собственные MCP-токены. JWT портала AI-клиентам не передаётся. +- MCP требует авторизацию даже при `AUTH_REQUIRED=false`. Управление доступом в web UI требует проверенного входа через портал. +- MCP-токены принимаются только MCP endpoint и необходимыми OAuth endpoint; обычный REST и `/api/mcp/*` их не принимают. Это проверяется отдельными тестами. +- Политики, отзыв, блокировка подключения и срок проверяются на каждом вызове и перед каждой новой стадией записи. Немедленный отзыв роли непосредственно в портале не обещать без отдельного механизма синхронизации; UI показывает момент последнего подтверждения прав владельца. +- Включить проверку Host/Origin для MCP, лимиты размера запроса и защиту browser-admin действий от CSRF. Секреты и токены не принимаются в query string. +- Адреса роутеров и внешних интеграций проверяются сервером: разрешённые назначения, схемы, порты и redirects. Приватные адреса разрешаются для зарегистрированной инфраструктуры; произвольный URL из аргумента модели не становится разрешённым назначением. + +### 2.3. Секреты + +- Пароли, приватные ключи, PSK, VPN-конфигурации и содержимое backup не возвращаются модели. Использовать `secretRef` и ссылки на подготовленные файлы; ввод и скачивание секретов — человеку в UI. +- Секреты хранятся зашифрованно; ключ шифрования задаётся вне БД. Ссылки проверяются по владельцу, назначению и области объектов. +- Маскирование применяется к ответам, ошибкам, планам, журналам, свободному тексту логов и экспорту. Сырые секретные payload не сохранять в журнале обращений. +- Текст устройств, комментарии и логи — недоверенные данные. Они не меняют политику и не исполняются как инструкции. + +### 2.4. Лимиты и временный доступ + +Начальные значения на подключение: 60 вызовов в минуту, 4 одновременных чтения, 10 запусков изменяющих операций в час, не более 5 серверов и 100 изменяемых объектов на план. Это исходные настройки, доступные для изменения администратором. + +- Одна одновременная запись на сервер; глобальные операции используют отдельную эксклюзивную блокировку. +- Бюджеты изменения учитывают фактические шаги и дочерние операции. Объединение в batch и повтор запроса не обходят ограничения; повтор по ключу идемпотентности не списывает бюджет повторно. +- Автоматическое восстановление резервирует бюджет при запуске исходного плана, чтобы исчерпание лимита не заблокировало заранее разрешённое восстановление. +- Превышение возвращает `RATE_LIMITED` или `BUDGET_EXCEEDED` с временем повторной попытки, где оно определимо. +- Временный доступ оформляется отдельным ограниченным grant внутри подключения: операции, серверы, срок. По умолчанию — 30 минут, максимум — 24 часа; он не расширяет базовые разрешения подключения. После истечения прекращается именно этот доступ. +- Проверки чтения не создают бессрочных полномочий на отложенное применение. Очередь и последующие этапы заново проверяют срок. +- Bandwidth-тесты имеют отдельное разрешение и обязательные границы скорости/длительности; один тест на затронутый роутер. Наблюдателю недоступны. Без явно заданного профиля нагрузки тест не запускается. + +### 2.5. Защищённые объекты и симулятор + +- Администратор отмечает защищённые серверы, management-интерфейсы, маршруты, firewall-правила и иные объекты по стабильному ID. Наследуемые зависимости также участвуют в проверке. +- Защита имеет два режима: `deny` и `human-approval-required`. Разрешение опасных автономных действий не снимает эту защиту. +- Начально автоматически защитить используемые управляющие интерфейсы/адреса и известные зависимости; неполное определение management-пути отображать как неизвестность и блокировать его автономное изменение. +- Изменять список защит может только человек с административным доступом MCP. Модель не может снять защиту перед своим действием. +- В UI реализовать симулятор: подключение + инструмент + параметры → `denied`, `approval-required` или `allowed`, эффективные лимиты и цепочка причин. Симулятор использует тот же evaluator, не резервирует бюджет и не меняет устройства. + +## 3. Планы, применение, аудит и восстановление + +### 3.1. Публичные интерфейсы и версии + +Доменные инструменты чтения и подготовки имеют отдельные схемы, например `mm_servers_list`, `mm_users_create_plan`, `mm_firewall_rule_update_plan`. Общие инструменты: `mm_capabilities_get`, `mm_change_get`, `mm_change_apply`, `mm_change_cancel`, `mm_operation_get`. + +- Списки возвращают источник, время наблюдения, предупреждения и курсор. По умолчанию — 100 объектов, максимум — 500, если раздел задаёт меньший предел. +- Единые коды ошибок: `PERMISSION_DENIED`, `APPROVAL_REQUIRED`, `STATE_CONFLICT`, `PLAN_EXPIRED`, `UNSUPPORTED_CAPABILITY`, `RATE_LIMITED`, `BUDGET_EXCEEDED`, `DEVICE_UNAVAILABLE`, `AUDIT_UNAVAILABLE`, `RESET_REQUIRED`. Неопределённый результат записи представлен состоянием операции, а не обычной повторяемой ошибкой. +- Реестр содержит версию инструмента и схемы. Совместимые добавления полей не меняют смысл разрешений. Несовместимое изменение получает новое имя инструмента с суффиксом версии; старый инструмент объявляется устаревающим и сохраняется минимум на один minor-релиз и 90 дней, если безопасность не требует немедленного отключения. +- Новые инструменты не добавляются автоматически к ранее выданным разрешениям. Сохранённый план привязан к версии схемы, обработчика и политики; несовместимый план становится неприменимым. +- Заполнить MCP-аннотации чтения, разрушительности, идемпотентности и внешнего доступа. Аннотации помогают клиенту, но не заменяют серверные проверки. + +### 3.2. План и состояния + +План неизменяем: ID, инициатор, подключение, версия политики/инструмента, серверы и объекты, изменения до/после, зависимости, отпечатки исходного состояния, риск, срок, порядок действий, проверки и доступное восстановление. + +- Срок обычного плана — 15 минут; оптимизационного — максимум 5 минут и не дольше пригодности измерений. +- Подтверждение относится к конкретному содержимому и одному запуску. Модель подтверждать планы не может. +- Перед записью повторно проверяются права, защищённые объекты, срок, бюджет и фактическая конфигурация. Изменение исходного состояния требует нового плана; пересчитывать одобренный план запрещено. +- `mm_change_apply(planId, idempotencyKey)` создаёт или возвращает существующую операцию. Область ключа — подключение и действие; одинаковый ключ с другим payload отклоняется. +- План: `draft`, `awaiting-approval`, `approved`, `executing`, `completed`, `cancelled`, `expired`, `invalidated`. Операция: `pending`, `running`, `applied`, `rejected`, `partial`, `unknown`. Проверка отдельно: `pending`, `passed`, `failed`, `inconclusive`. +- Длительная работа возвращает `operationId`. Отключение AI-клиента не перезапускает её. Отмена гарантируется до первого внешнего шага; после начала возможна только безопасная остановка между шагами с фиксацией результата. +- UI, MCP и контроллер используют общие блокировки. Истечение lease не означает право повторить неопределённую запись: сначала сверка и ограждение старого исполнителя. + +### 3.3. Массовые изменения и контроль расхождений + +- Для независимых изменений на нескольких серверах обязательна первая пробная стадия на одном сервере. Сервер задаётся в плане; по умолчанию — минимальный разрешённый ID среди целей. +- Только после успешной проверки продолжается применение по одному серверу. Между стадиями повторно проверяются права, срок, бюджет и конфигурация. Сбой останавливает оставшиеся стадии. +- Связанные изменения сетевого пути могут затронуть соседние устройства ещё на первой стадии. План показывает эту область влияния; такие действия не выдаются за независимую пробу и требуют явно проверенного порядка зависимостей. +- Первые 24 часа после успешного применения проверять отпечатки затронутой конфигурации раз в минуту; затем для активных профилей Route AI — в его цикле, для остальных — по запросу пользователя. Сравнивать конфигурацию, исключая динамические счётчики и временные поля. +- UI показывает ожидаемое/фактическое состояние, время обнаружения и связанные операции. Причину «ручное изменение» ставить только при наличии доказательства; иначе — «внешнее изменение, источник неизвестен». +- Чужое изменение приостанавливает затронутый профиль, инвалидирует конфликтующие планы и требует явного возобновления. Автоматического возврата к желаемому состоянию нет. + +### 3.4. Надёжность аудита и восстановление + +- Сохранить намерение и начало операции до первого изменения устройства. При недоступном аудите новые записи не начинаются; вернуть `AUDIT_UNAVAILABLE`. +- Перед каждым внешним шагом сохранять его намерение. Если после записи сохранить результат не удалось, остановить следующие шаги; после восстановления считать незавершённый шаг неопределённым и сверить состояние. +- Журнал обращений, административный аудит и события RouterOS — разные сущности. Обращения включают чтение, отказы и ошибки; административный аудит — выдачу/отзыв доступа, политики и подтверждения. +- Потеря ответа не доказывает, что запись не состоялась. При `partial`/`unknown` не повторять действие автоматически. Между роутерами транзакционной атомарности нет. +- Использовать существующие ревизии, где они есть; явно показывать отсутствие автоматического отката в других разделах. Восстановление — отдельный план с проверкой отсутствия чужих изменений. +- Для восстановления самой БД вести fsync-журнал обслуживания в постоянном volume вне восстанавливаемой PostgreSQL: только идентификаторы, хеши и состояния без секретов. Недоступность этого журнала блокирует восстановление. После операции импортировать квитанцию в аудит. +- До восстановления БД закрывать streams, останавливать фоновые задачи и получать эксклюзивную блокировку. После него аннулировать MCP-токены, OAuth-разрешения, планы и grants автопилота через смену поколения доступа, сохраняемого вне восстанавливаемой БД. +- После рестарта сверять незавершённые операции; не считать их автоматически успешными или неуспешными. + +## 4. Самостоятельный web-раздел MCP + +Использовать существующий стиль base-nova и компоненты ReUI. Раздел находится в основном меню, а не внутри общих настроек. + +| Вкладка | Содержимое | +| --- | --- | +| Обзор | Недавняя активность, выполняемые операции, подтверждения, ошибки, отказы, бюджеты и выключатель MCP | +| Подключения | Выпуск/отзыв токенов, OAuth-согласия, сроки, временный доступ, инструкции HTTP/stdio и проверка подключения | +| Разрешения | Режимы, серверы, инструменты, опасные действия, лимиты, защищённые объекты и симулятор | +| Обращения | Все вызовы инструментов, чтение, отказы, ошибки и связи с операциями | +| Планы и операции | Изменения, одобрение/отклонение, стадии массового применения, сверка и восстановление | +| Настройки | Общие пределы, OAuth-клиенты, сроки хранения, сетевые назначения и версия API | + +### Права web-раздела + +- `mm:mcp:read` — обзор, обращения и операции в разрешённой области. +- `mm:mcp:approve` — одобрение; дополнительно необходимы права на само действие и все объекты. +- `mm:mcp:admin` — подключения, политики, защиты, OAuth-клиенты и настройки. +- `mm:mcp:pause` — остановка новых запусков MCP. +- Существующий `mm:settings:admin` даёт административный доступ к разделу для начальной настройки. Новые права включить в контракт интеграции с порталом. Права управления MCP не публиковать как действия MCP-инструментов. + +### Выдача и проверка доступа + +- Мастер: владелец → серверы/инструменты → режим/опасные действия → лимиты/защиты → срок → проверка политики → выдача. +- Карточка показывает права, срок, способ входа, последнее обращение и причину блокировки. Статус активности основан на обращениях, а не на выдуманном постоянном соединении stateless HTTP. +- Изменение политики показывает diff и фиксируется в аудите. Отзыв блокирует новые вызовы/стадии и закрывает ожидания follow; уже отправленная RouterOS-запись не считается отменённой. +- Проверка подключения выполняет initialize/согласование возможностей и разрешённое чтение без создания плана и без записей на роутер. Backend-проверка использует реальный механизм авторизации с внутренним credential, который не показывается UI и немедленно отзывается; отдельно дать инструкции проверки из фактического AI-клиента. + +### Мониторинг + +- Обращение содержит ID, время, подтверждённую личность, подключение, инструмент/версию, серверы/объекты, решение политики, длительность, код результата, plan/operation/job ID. +- Карточка показывает нормализованные и маскированные параметры/результат с лимитом размера и признаком усечения. Сырые Authorization headers, секреты и неограниченные тела не сохраняются. +- Фильтры: время, подключение, пользователь, инструмент, сервер, результат. Пагинация серверная, фильтры — в URL. SQL ограничивает область до агрегации и выдачи. +- Ссылки ведут к плану, операциям, конфигурационным расхождениям и отфильтрованному `/logs`. Записи глобальных действий доступны только при соответствующем глобальном праве. +- Неавторизованные обращения сохраняются с минимальными метаданными, без токена и произвольного тела; повторяющиеся отказы ограничиваются по частоте с учётом подавленных событий. +- Обновление раз в 5 секунд, пока вкладка видима. Показать время последнего успешного обновления и деградацию при ошибке. +- Выключатель MCP запрещает новые вызовы/запуски и закрывает follow. Уже начатые записи завершаются или сверяются. Route AI имеет отдельную аварийную паузу; дополнительно дать объединённое действие «Остановить автоматические изменения», включающее оба механизма. + +## 5. Покрытие приложения, пользователи и интерфейсы + +### 5.1. Существующие разделы + +Включить чтение и поддерживаемые изменения серверов, `/users`, BGP/OSPF, маршрутов, фильтров, firewall, GRE, WireGuard, IPsec, VXLAN, контейнеров, мониторинга, трафика/NetFlow, событий, алертов, сертификатов, backup, расписаний, интеграций и системных настроек. Загрузка/скачивание секретных файлов остаются в человеческом UI через ссылки. + +### 5.2. Пользователи `/users` + +- Список, карточка, создание, редактирование, удаление, активность, роли, настройки прав и привязки к серверам/интерфейсам/peers. +- Изменение ролей/прав требует `mm:settings:admin`; прочие операции — существующие `mm:users:*`. +- Выдача ограничивается видимыми серверами/привязками; редактирование пользователя с зависимостями вне области подключения запрещается. Не показывать скрытые права и привязки как отсутствующие: отмечать неполноту карточки. +- Проверять существование, однозначность и уникальность привязок. Изменения `/users` не меняют JWT-разрешения портала. + +### 5.3. Новое управление интерфейсами + +Добавить общие сервисы, REST API, UI и инструменты подготовки планов. + +| Объект | Возможности | +| --- | --- | +| Ethernet | Состояние, включение/отключение, имя, comment, MTU и подтверждённые параметры линка | +| Bridge | Создание, настройка, удаление; порты и их параметры | +| VLAN | VLAN-интерфейсы, bridge VLAN membership и filtering | +| IP | Создание, изменение, отключение и удаление IPv4/IPv6-адресов | +| Туннели/VPN | Существующие GRE, WireGuard, IPsec и VXLAN | + +- Физические Ethernet-интерфейсы не создавать/удалять. Динамические и аппаратные свойства не изменять без подтверждённой поддержки. +- Проверять зависимости адресов, bridge, VLAN, VRF, маршрутов, firewall, туннелей, probes и пользователей. Использовать стабильный RouterOS ID и отпечаток, а не только имя. +- VLAN: валидировать parent, ID, tagged/untagged, PVID и CPU/management-доступ. VLAN filtering применяется как связанный план с явным порядком действий. +- Не выполнять неявное каскадное удаление. План перечисляет все изменения зависимостей. +- Новые DTO сохраняют реальные типы bridge/VLAN; существующую упрощённую классификацию пользовательских привязок расширять обратно совместимо, не подменяя неизвестный тип Ethernet. + +## 6. Раздел `/logs` через MCP + +Переиспользовать существующие контракты, PostgreSQL и `router-logs-query`/`router-logs-collector`. Второй сборщик не создавать. + +| Инструмент | Назначение | +| --- | --- | +| `mm_logs_query` | История, фильтры, поиск, курсор | +| `mm_logs_get` | Одна запись | +| `mm_logs_context_get` | До 10 событий до/после на том же сервере | +| `mm_logs_summary_get` | Статистика и состояние сбора доступных серверов | +| `mm_logs_follow` | Ограниченное ожидание новых событий | +| `mm_logs_export` | Подготовка CSV/JSON с SQL-бюджетом и квотой хранения | +| `mm_logs_export_read` | Чтение подписанной scoped-выгрузки частями base64 с SHA-256 | +| `mm_logs_settings_get` | Административное чтение настроек | +| `mm_logs_settings_update_plan` | План изменения сборщика | + +- Сохранить фильтры: сервер, период, уровни, topics, буквальный регистронезависимый поиск, presets `login-failure`, `interfaces`, `routing`, `firewall`. В MCP уровни/topics — массивы; значения поля объединяются OR, поля — AND. +- Чтение — `mm:servers:read`, настройки — `mm:settings:admin`. Ограничения серверов применяются в SQL до подсчёта, пагинации и экспорта. Summary не выдаёт административный блок настроек. +- История отключённых/удалённых серверов доступна по разрешённому историческому ID. Чужая запись по ID возвращается как не найденная. +- По умолчанию последние 15 минут, 100 записей, максимум 200. Сохранять watermark и непрозрачный курсор, привязанный к фильтрам/области и поколению данных. +- Follow: один включённый сервер, до 30 секунд и 200 событий. Использовать существующую подписку ускоренного опроса, гарантированно освобождая её при завершении/отмене/отзыве. Общий предел — четыре опрашиваемых устройства. +- Follow не включает отключённый сборщик. При обслуживании/восстановлении БД закрывать ожидания, менять поколение курсоров и возвращать `RESET_REQUIRED`. +- Экспорт: те же права/фильтры/маскирование, максимум 50 000 строк, нейтрализация CSV-формул. Результат — авторизованная ссылка на файл со сроком 15 минут и повторной проверкой доступа при скачивании. +- Настройки сохраняют диапазоны 5–3600 секунд и 1–365 дней. Отключение сбора/сокращение хранения — опасные изменения; план показывает период последующего удаления. +- Сохранять `rawTime`, `eventAt`, `receivedAt`, `effectiveAt`, `timeKind`, `generation`, ошибки и возможные разрывы. Время без timezone не считать UTC. Отсутствие событий не доказывает исправность. +- События маршрутизации и интерфейсов связываются с окнами операций Route AI как дополнительные свидетельства, а не как единственный триггер переключения. + +## 7. Route AI, OSPF, ECMP, RPF и VRF + +### 7.1. Общий анализ и инструменты + +- Перенести расчёт из браузера на сервер; UI/MCP используют один алгоритм и настройки. +- Разделять измеренные RTT/потери/bandwidth, загрузку, модельные оценки и неизвестные значения. Загрузка не считается пропускной способностью; backend latency не считается RTT сетевого пути. +- Метрика содержит источник, время успешного измерения, направление, интерфейс, семейство адресов и VRF/таблицу. DL/UL нормализуются относительно анализируемого пути. Ввести отдельное время успешного bandwidth-теста. +- Начальная свежесть: RTT/потери — 5 минут, bandwidth — 60 минут. Модельные значения разрешены для сценариев, но не автономной записи. Неизвестное не заменяется нулём или успешным состоянием. +- Профили Community задают разрешённые WAN/узлы/выходы/таблицы, закрепления, обязательные ограничения качества, веса и ECMP. +- Начальные веса: RTT 60%, скорость 30%, потери 10%. Рейтинг 0–100: RTT `max(0,100-RTT*0.6)`, speed `min(100,(DL+UL)/18)`, loss `100-lossPct`; взвешенная сумма округляется. Это эвристический рейтинг, не вероятность и не доказательство глобального оптимума. +- Неизвестная метрика не перераспределяет вес в пользу кандидата. Для автономности все метрики с ненулевым весом должны быть свежими и измеренными. Сначала ограничения/закрепления, затем рейтинг; нет подходящего пути — сохранить состояние и объяснить причину. +- Инструменты: `mm_routing_topology_get`, `mm_routing_measurements_query`, `mm_routing_measurement_plan`, `mm_route_optimizer_analyze`, `mm_route_optimizer_compare`, `mm_route_optimizer_plan`, `mm_ospf_optimizer_plan`, `mm_ecmp_plan`, `mm_rpf_plan`, `mm_vrf_plan`, `mm_routing_verify`, `mm_route_controller_get`, `mm_route_controller_update_plan`. +- По умолчанию вернуть лучшие 20 вариантов, максимум 100, с фильтрами. Не материализовать неограниченный декартов набор путей. + +### 7.2. Community и рекурсивные маршруты + +- Определять фактическую конфигурацию из RouterOS, управляемых фильтров, GRE и рекурсивных маршрутов. Настроенный gateway и наблюдаемый путь — разные поля. +- Цепочка Home → JumpHost → Exit подтверждается наблюдениями в нужных интерфейсах/таблицах, а не одним рейтингом. Если путь не доказан, не показывать его как текущий факт. +- Новый gateway выбирать из реального каталога; проверять next-hop resolution, таблицу, scope/target-scope, family и туннель. Не копировать текущий gateway в рекомендацию другого пути. +- Учесть, что существующий рекурсивный редактор заменяет весь управляемый набор: план показывает весь затронутый набор. Неуправляемые маршруты/фильтры сохраняются; конфликт владения блокирует запись. +- Реализовать отдельный IPv6-адаптер, не применять IPv4-парсер к IPv6. + +### 7.3. OSPF + +- Сохранять конкретные template ID, интерфейсы, instance/area, OSPFv2/v3, VRF и cost до/после. Применять сохранённый план без повторного ранжирования. +- Неоднозначные router-id/peer/interface, неизвестные значения и пробы другого назначения блокируют автономное применение. Область чтения вспомогательных серверов также проверяется по правам. +- Поддержать два режима: основной/резервные пути и равностоимостная ECMP-группа. Для ECMP выбранные пути получают равные cost; резервные — более высокие допустимые стоимости. Упорядочивание по rank не должно незаметно уничтожать ECMP. +- Сохранить исходные cost для восстановления; перед восстановлением убедиться в отсутствии чужих изменений. +- Проверять costs, соседства, BFD, маршруты и контрольную связность. Локальная оптимизация не объявляется глобальной оптимизацией сети. + +### 7.4. ECMP, RPF и VRF + +- ECMP — управляемые равностоимостные статические/рекурсивные маршруты и OSPF costs. BGP multipath — только при подтверждённой поддержке конкретного устройства. +- «Максимум путей» ограничивает выбор контроллера; не представлять его универсальным параметром RouterOS. Hash-policy соответствует реальным свойствам устройства; обычный ECMP не предоставляет per-packet. +- Глобальная hash-policy показывает влияние на весь роутер. IPv4 и IPv6 — отдельные планы/проверки. +- Проверять NAT, connection tracking, FastTrack и асимметрию; сторонние правила не переписывать автоматически. +- RPF для IPv4: `disabled` отображается в RouterOS `no`; `loose` и `strict` проверяются на совместимость с policy routing/асимметрией. Для IPv6 явно возвращать неподдерживаемость этой настройки. +- Разделять VRF, routing table и routing rule. Поддержать создание, состав, порядок и удаление с проверкой зависимостей. +- Перенос интерфейса показывает влияние на адреса, routing instances, туннели, probes и management. Route leaking — только явно заданными маршрутами, без автоматического обмена всеми таблицами. +- Capabilities проверяются по версии RouterOS и доступным свойствам, а не только по номеру версии. + +### 7.5. Серверный контроллер + +- Работает без AI-клиента; отдельный человеческий grant задаёт профиль, срок, серверы, действия, бюджеты и защиты. По умолчанию выключен. MCP может готовить изменение настроек, но не расширять grant. +- Цикл — раз в минуту, без автоматического запуска bandwidth-тестов. Обычное переключение: улучшение минимум 15 пунктов в трёх свежих наблюдениях; cooldown — 10 минут. +- Гистерезис — 10 пунктов: ожидающее переключение сбрасывается при преимуществе ниже 5. Повторное чтение той же пробы не считается новым наблюдением. +- Отказ текущего пути — три последовательные неуспешные проверки и подтверждённый резерв. Неизвестность не считается отказом. Failover не обходит защиты, права и аудит. +- Предел переключений профиля — 3 в час, включая failover и восстановление; дальнейшие действия требуют человека. Зарезервированное восстановление незавершённой операции допускается в её рамках. +- После старта сверять фактическую конфигурацию. При ручном/внешнем изменении, `partial`, `unknown` или неуспешной проверке приостановить затронутую область. +- Один исполнитель на профиль через lease; записи дополнительно защищены блокировкой сервера. Аварийная пауза не требует подготовки плана. +- Проверка после изменений — до 120 секунд: маршруты, next-hop, соседства/BFD, контрольная связность и сопутствующие `/logs`. +- Автовосстановление разрешено только заранее выданной политикой, при известном исходном состоянии и отсутствии чужих изменений. Невозможность восстановить устройство после потери управления не скрывать. +- Автономные изменения management-интерфейсов допустимы только при зарегистрированном и проверенном независимом канале управления и соответствующем допуске; иначе требуется человек и не действует обход защищённых объектов. + +#### Следующий этап: scoped controller и подтверждённое восстановление + +Read-only аудит 24 октября подтвердил: ручной scoped executor ещё не реализует весь цикл. Отдельный зашифрованный configuration checkpoint до post-probe и signed post-probe receipt теперь реализованы; подтверждённый отказ отличается от unknown. Profile CAS и per-profile lease реализованы. Проверка management-канала всё ещё требует доступности primary и alternate, сам PATCH использует primary. Inverse engine, alternate transport и profile-version у transport отсутствуют; снятие guards профиля до полной приёмки не допускается. + +- [ ] Сохранить зашифрованное исходное тело одной owned rule, исходный candidate ID, original/desired/dependency proofs, native ID, endpoint epochs, profile/version и operation/receipt ID. Публичный preview показывает только изменение Community/gateway. +- [x] Точный PATCH/read-back отделён от durable encrypted `CONFIG_APPLIED` и последующей подписанной пробы. Lost/ignored/collateral PATCH или неподтверждённый read-back не создаёт checkpoint: ни повторного PATCH, ни автоматического inverse. Положительная конфигурация после lost response не подменяет отсутствующую квитанцию. +- [ ] После подтверждённой записи проверять desired context, route/next-hop, BGP/BFD, control probe и связанные `/logs` до 120 секунд. Только подтверждённый отказ допускает заранее разрешённый inverse; неизвестность приостанавливает профиль. +- [ ] Один inverse engine для ручного recovery plan и автоматического восстановления: тот же owned native ID и точное исходное тело. Непосредственно перед intent и transport нужны свежие desired-state/dependency proofs, SLA и проба исходного кандидата в текущем after-state context; прежняя досвитчевая подпись недостаточна. +- [ ] Резервировать бюджет forward/inverse заранее. Срок recovery ограничить 120 секундами и минимальным сроком profile grant, owner/credential/policy/channel. Один durable recovery intent; после него повторить проверки прав, scope, epochs, защиты и аварийной паузы. Потерянный inverse ответ не повторять; reconcile/restart только читает. +- [ ] Реализовать фиксированный заранее разрешённый alternate transport с сохранённой идентичностью RouterOS и channel ID/generation/expiry. При потере primary проверять alternate независимо; не использовать его для повтора неизвестной forward-записи. +- [ ] После inverse сохранить original-state proof, успешную пробу и отдельный `RECOVERY_VERIFIED` checkpoint. Controller хранит ожидаемый proof вместе с candidate ID; lease/server locks и CAS версии профиля не позволяют старому tick перезаписать новый человеческий grant. +- [ ] SQL/HTTP: успешный switch; подтверждённый post-fault → verified inverse; неисправный исходный кандидат; unknown forward/no inverse; lost inverse/no replay; отзыв grant/epoch/protection до и после intent; restart/reconcile; concurrent tick/profile edit; emergency pause. Включать scoped controller только после этой приёмки. +- [ ] Два CHR использовать для exact native PATCH/read-back, pairwise IPv4/IPv6 probes, отказов и очистки. Независимые Home → JumpHost → ExitNode, прямой/обратный маршрут и end-user traffic failover остаются отдельной приёмкой с третьим физическим узлом; пользователь подтвердил, что сейчас его нет. + +#### Следующий этап WireGuard: полный цикл конфигурации + +- [x] Собственный временный отключённый WG fixture на двух CHR подтвердил непустое generic/WireGuard ID соответствие и basic PATCH; объекты удалены с восстановлением baseline. Это отдельное наблюдение до финального import/activation helper, а не приёмка forwarding/key rotation. +- [x] Общие codecs соблюдают `includePrivateKey=false`, безопасно экранируют RSC и сохраняют disabled peers. Строгий bounded import отклоняет неподдерживаемые строки/поля, несколько интерфейсов, чужую binding и непредставимые параметры; ошибки и preview не раскрывают ключи. +- [ ] `network.wireguard.export` / `mm_wireguard_export_plan`: зашифрованный временный human-only artifact, скачивание владельцем через портал, receipt projection и проверки отзыва, expiry, source drift и tamper. +- [x] `network.wireguard.import.preview` / `mm_wireguard_import_preview` принимает owner/purpose/server-bound `contentRef` с purpose `wireguard-import-config`; preview показывает все поддержанные поля, counts и только наличие ключей. Raw config не попадает в публичные планы/receipts. +- [x] `network.wireguard.import` / `mm_wireguard_import_plan`: общий REST/MCP guarded multi-peer executor, disabled preparation → staged activation, private source/dependency proofs, locks, per-command intents/fences и полный read-back. Компенсация и повтор unknown запрещены. SQL/HTTP подтвердил IPv4/IPv6, несколько peers, secret refs, partial/lost outcomes, no replay и отсутствие утечек; native import/activation остаётся отдельной приёмкой. +- [ ] Отдельно `config.wireguard.revisions.restore` / `mm_wireguard_revision_restore_plan`: строгая bounded проверка revision/payload, явный inventory удалений, keys и clearing полей, dependencies/protections/NetFlow, command budgeting и whole-section prefix verification. Некорректный snapshot нельзя превращать в пустую секцию и удалять существующие объекты. + +## 8. Хранение, эксплуатация и этапы + +### Данные и начальные настройки + +- PostgreSQL: подключения, credentials/grants, OAuth-клиенты/согласия, secret references, политики/версии, защищённые объекты, планы, операции/шаги, обращения, административный аудит, профили/закрепления и решения Route AI, leases и расхождения. +- Состояние управления восстановлением БД: минимальный журнал обслуживания и поколение доступа на постоянном volume вне PostgreSQL. Ключи шифрования и подписи не хранить внутри backup БД. +- `/logs` использует существующие таблицы и собственную retention-политику. Новые данные MCP входят в полный backup и очистку с учётом правил аннулирования доступа. +- Обращения — 30 дней, административный аудит и завершённые операции — 180 дней, готовые файлы экспорта — 15 минут. Незавершённые операции/ожидающие планы автоматической очисткой не удалять; планы по истечении переводить в `expired`. +- Уменьшение retention — административное изменение с показом последствий. Удаление файлов и записей выполняется пакетами. +- Route AI хранит настройки на сервере. Старые браузерные значения импортируются явно, однократно и версионированно; `autoApply` не переносится автоматически. +- Наблюдаемость backend: число вызовов/отказов, задержки, очередь, лимиты, состояние аудита, неопределённые операции, цикл контроллера и активные follow. В метрики не включать секреты и неограниченные labels объектов. +- MCP и контроллер изначально выключены; новые подключения — наблюдатели. Не запускать MCP в открытом режиме при отсутствии конфигурации авторизации. + +### Этапы реализации + +1. Реестр покрытия, общие контракты, evaluator политик, блокировки, аудит и границы REST/MCP credentials. +2. MCP HTTP/stdio, OAuth/токены, версии инструментов и базовое чтение. +3. Самостоятельный web-раздел `/mcp`: выдача доступа, разрешения, симулятор, обращения и проверка подключения. +4. Все существующие операции приложения, `/users` и `/logs`; планы, подтверждения, лимиты и защищённые объекты. +5. Ethernet, bridge, VLAN и IPv4/IPv6-адреса в API/UI/MCP; контроль зависимостей. +6. Исправленный OSPF pipeline, серверный анализ Route AI и реальное применение Community/IPv6. +7. ECMP/RPF/VRF, контроллер, поэтапное применение, проверка результата и контроль расхождений. +8. Стендовая приёмка, документация подключения, эксплуатационные инструкции и поэтапное включение: наблюдение → подтверждения → ограниченная автономность. + +## 9. Проверки и критерии приёмки + +- Права: три режима, обе политики опасных действий, ограничения серверов/полей, защиты зависимостей, временные grants и отсутствие саморасширения доступа. +- Авторизация: PKCE, redirect URI, audience/resource, истечение, rotation/replay refresh-токенов, отзыв, запрет MCP-токенов в REST/admin API, Host/Origin/CSRF и сетевые назначения. +- Политики: симулятор и реальный evaluator дают одинаковое решение на неизменном состоянии; бюджет/защита не обходятся batch, alias инструмента, временным grant или повтором. +- Планы: точное применение одобренного содержимого, конфликт версий, идемпотентность, гонка UI/MCP/контроллера, отмена, остановка массовой операции после первой неуспешной стадии. +- Аудит: недоступность до записи блокирует действие; сбой после отправки порождает сверку; восстановление БД не возвращает действующие токены, одобрения и grants. Внешний журнал переживает restore. +- Web UI: одноразовая выдача токена, роли вкладок, фильтры/пагинация, детали отказов, усечение/маскирование, скрытая вкладка не опрашивает backend, отзыв и аварийная остановка отражают фактический результат. +- Пользователи: CRUD/активность/роли/привязки, скрытые зависимости, уникальность, отсутствие воздействия на аккаунты портала/RouterOS. +- Интерфейсы: стабильная идентичность, переименование, зависимости, VLAN/PVID/filtering, CPU-port, IPv4/IPv6 и защищённый management-путь. +- Логи: SQL-ограничения до агрегации, чужие ID, история удалённых устройств, watermark, смена фильтров, follow/отмена/отзыв, освобождение подписок, разрывы и reset, экспорт 50 000, CSV и истечение ссылки. +- Метрики Route AI: точное направление DL/UL, family/VRF/interface/peer, freshness, повтор старой пробы не считается новым наблюдением; модель и загрузка не выдают себя за bandwidth. +- Маршрутизация: реальные gateway, owned/unmanaged конфликты, весь набор рекурсивных маршрутов, OSPFv2/v3 и равные costs, ECMP hash-policy, NAT/FastTrack/асимметрия, RPF и VRF/route leaking. +- Контроллер: закрепления, ограничения качества, hysteresis/cooldown, максимум переключений, единственный исполнитель, старт после рестарта, ручное вмешательство, неизвестный исход и бюджет восстановления. +- Совместимость: официальный MCP Inspector, HTTP и stdio, клиент с OAuth и клиент с Bearer; тесты схем и миграций реестра разрешений. +- Запустить соответствующие существующие backend/контрактные тесты, logs PostgreSQL-интеграцию и новые MCP/web E2E. Нагрузку проверить на истории миллиона логов и больших каталогах; подтвердить ограниченную выдачу и отсутствие второго collector. +- Сетевую приёмку проводить на нескольких CHR с Home/WAN/JumpHost/Exit, IPv4/IPv6, OSPFv2/v3 и двумя VRF: отказ/возврат линка, асимметрия, потеря подтверждения, частичная запись, потеря управления и независимое восстановление. Новые сценарии сначала проверяются без production-изменений. + +Итоговый критерий: администратор выдаёт проверяемый ограниченный доступ в отдельном разделе MCP, видит все обращения и решения политик, подтверждает изменения и отзывает доступ; UI, MCP и контроллер исполняют одинаковые правила, не обходят лимиты/защиты и показывают подтверждённые изменения отдельно от фактического результата проверки сети. + +## 10. Источники и связанные документы + +- [Логи MikroTik](logs.md) — существующий сборщик, API и ограничения. +- [Интеграция с auth-portal](integrate-auth-portal.md) — существующая идентификация и JWT-права. +- [MCP Authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization). +- [MCP TypeScript SDK](https://ts.sdk.modelcontextprotocol.io/v2/get-started/packages). +- [MCP Tool Annotations](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/). +- [RouterOS IP Routing](https://help.mikrotik.com/docs/spaces/ROS/pages/328084/IP%2BRouting). +- [RouterOS IP Settings](https://help.mikrotik.com/docs/spaces/ROS/pages/103841817/IP%2BSettings). +- [RouterOS VRF](https://help.mikrotik.com/docs/spaces/ROS/pages/328206/Virtual%2BRouting%2Band%2BForwarding%2B-%2BVRF). + +### Управляемые Community-фильтры и ревизии, 20 октября + +- [x] `mm_filter_rules_replace_plan` и `mm_filter_revision_restore_plan`: общий REST/MCP `applyFiltersToServer`, совместные filters-write/network-read/BGP-read права, обязательное подтверждение человеком, scoped checksum ревизии и private HMAC источника/зависимостей. Реестр: 302 инструмента, REST 145 mediated / 62 excluded / 55 pending из 262. +- [x] Полная замена только одного собственного набора `bgp-in`, сохранение чужих цепочек и порядка, запрет неоднозначного ownership и удаления чужих Community-правил. Пустой список удаляет только собственный набор. Native read-back выполняется до сохранения метаданных и ревизии; unknown/partial не повторяются автоматически. +- [x] До dispatch повторно проверяются GRE/recursive, BGP/templates, OSPF/RIP/IS-IS, filter-select, таблицы/VRF, адреса/маршруты и server-wide protections. Metadata-only/no-op также проходят общий guard; необязательный `requireUnbound` требует отсутствия routing contexts. +- [x] Компилятор RouterOS 7 использует `set blackhole yes` и `set gw-interface` по проверенному имени GRE. Названия и описания Community остаются метаданными менеджера: `#` в routing-filter не поддерживается. Произвольные CLI escapes и menu IDs не используются. Для GRE поддержан проверенный ASCII-набор имени; кавычки, `$`, обратная косая черта и управляющие/разделительные символы отклоняются до записи, без автоматического переименования. +- [x] [Два CHR](mcp-chr-filter-write-acceptance.json), завершено 20 октября в 14:30:15 UTC: собственный отключённый GRE с пробелом в имени, blackhole/route, replace/no-op/copy/restore, round-trip многострочного описания. По 11 baseline fingerprints восстановлены. Предыдущие ошибки native compiler сохранены отдельно; оставшийся fixture удалён [отдельным подтверждённым планом](mcp-chr-filter-write-recovery.json). +- [ ] Распространение и выбор маршрутов по Community в работающем BGP, независимые пути и полный аварийный сценарий остаются частью незавершённой сетевой приёмки. + +### Сводка мониторинга, 20 октября + +- [x] Общий REST/MCP reader и `mm_uptime_overview_get`: SQL scope до samples/provenance, два уровня страниц, ограничения 1000 проб/20000 samples, архив выключенных серверов без нового опроса устройств. +- [x] Отдельная проекция MCP сохраняет unknown и настоящий ноль, timestamps/возраст, historical fallback и его истечение; будущие samples исключены и из REST. Сохранённые метрики не объявляются подтверждением текущего пути. +- [x] SQL подтвердил публичные лимиты, REST parity, скрытые 20001 samples и реальные blocked-read source/probe/owner/pause/cancel гонки. MCP/coverage, build, lint, probes/SQL logs и настоящий restore прошли; [отчёт](mcp-uptime-overview-acceptance.json). Текущий реестр: 303 tools, REST 146/62/54. + +### DNS-сопоставление GRE endpoint, 20 октября + +- [x] `mm_network_hosts_resolve` использует общий REST resolver: explicit global + прежнее network-write право, до 32 hostname/IP, IDNA, cache/pending dedup, 512 cache entries, 8 незавершённых lookup, 2 s/lookup и 10 s/пакет. Timeout/отмена сохраняют pending slot до реального завершения OS lookup. +- [x] Нули/unknown и literal IPv6 отделены; manager resolver не объявляется RouterOS resolver/проверкой пути. Mock/HTTP проверили бюджет, кэш, отзыв, отмену и REST disconnect; SQL подтвердил policy/observer/owner/pause без внешнего DNS или native записи. Реестр: 304 tools, REST 147/62/53. [Отчёт](mcp-network-host-acceptance.json). +- [ ] Трёхузловая приёмка остаётся открытой. Read-only проверка Debian-стенда: QEMU и `/dev/kvm` отсутствуют, 1 CPU, 1.9 GiB RAM, 3.6 GiB свободного диска. Это инвентаризация готовности, без установки/запуска VM или изменения стенда; [отчёт](mcp-chr-three-node-readiness.json). + +- [x] Финальный общий контроль Community/uptime/DNS: 26/26 backend-профилей, MCP/SQL, SQL logs, настоящий pg_dump/restore, build/lint/models/coverage/diff-check. [Отчёт](mcp-routing-monitoring-regression-acceptance.json). Этот checkpoint не закрывает оставшиеся 53 REST-сценария и полную сетевую/external client приёмку. + +### Привязка BGP-фильтров, 21 октября (результаты в UTC) + +- [x] `mm_bgp_filter_binding_plan`: существующая input/output цепочка одного static peer, human-only и совместные BGP/filters/network права; полный private proof connection/filter/template/VRF/table, name/ID и native defaults, local override и nil/empty, guards/protections/no-op и read-back без неявного refresh. +- [x] Общий static BGP reader принят моделями и BGP export/binding SQL: точная negative D-flag query, dynamic отличается от unknown, invalid disabled/get/selection отклоняются. +- [x] [Два CHR](mcp-chr-bgp-filter-binding-acceptance.json): input/output отклоняют IPv4 через unmatched Community/implicit reject; clear восстанавливает пакеты, IPv6 сохраняется. Отдельные human peer restarts, 26 BGP packet checks (17 passed / 9 confirmed loss), все 22 baseline fingerprints восстановлены, SSH закрыт. Exit 0, 20 октября 18:24:44 UTC. +- [x] SQL/HTTP проверил default/ID contexts, template cycle, private/source/credential/no-poll/late-intent proof, protections для no-op и unknown без повторной записи. Полная регрессия 26 профилей + MCP/SQL/real restore/logs и build/lint/models/coverage прошла. [Отчёт](mcp-bgp-filter-binding-acceptance.json). Реестр 305, REST 147/62/53. +- [x] Типизированный `mm_bgp_community_policy_plan`: IPv4/IPv6 CIDR, exact/subnet с границами длины, keep/set/append/clear стандартных Community, accept/reject/continue, default action; создание/замена/удаление одной owned output-цепочки. Привязка — отдельным BGP-планом. SQL проверяет conjunctive permissions, обязательное подтверждение человека, native query ссылок, application intent, credentials/no-poll, защиты, partial/unknown и отсутствие повтора. +- [x] Native lifecycle export policy на двух RouterOS 7.24.5: exact IPv4/IPv6, subnet range/single length, set/append/clear, no-op, delete и сохранение существующей `bgp-in`. Учтён производный `.nextid` при сравнении чужих правил после записи; порядок/содержимое и raw source fence сохранены. Все 22 fingerprints восстановлены, exit 0. [Отчёт](mcp-chr-community-policy-acceptance.json). Передача атрибутов этим тестом не подтверждается. +- [x] Исправлена проверка чтения фильтра на RouterOS 7.24.5: простое имя `gw-interface` возвращается без кавычек. Нормализуется только эта форма; остальные команды/значения/порядок и raw source proofs сохранены. На двух CHR прошли IPv4/IPv6 replace, no-op без записи, copy, revision restore и восстановление всех 22 fingerprints. Сборка, lint, модели и полная регрессия 26 профилей + MCP SQL/real restore/logs прошли. [Отчёт](mcp-routing-filter-readback-acceptance.json). +- [x] Двухузловая функциональная Community-приёмка: set/append/clear, оба native tag после append, положительный active/blackhole в operational RIB, IPv4/IPv6 isolation и восстановление маршрута. 18 packet proofs: 12×3/3 с подтверждённым traceroute, 6×3/0. Штатный harness завершился ошибкой 401 при BGP cleanup; отдельное recovery по сохранённому marker восстановило все 22 fingerprints. Это не успешное завершение всего harness и не независимые физические пути. [Сводный отчёт](mcp-bgp-community-dual-afi-acceptance.json), [исходный прогон](mcp-chr-bgp-community-dual-afi-cleanup-interrupted.json), [recovery](mcp-chr-community-dual-afi-recovery.json). + + +### Глобальные счётчики — 21 октября + +- [x] `mm_sidebar_counts_get` / `system.sidebar.counts.read`: общий REST/MCP сервис, явный global allow и все права источников с обычным RBAC-наследованием, лимит каталога 100 до native reads, неизвестное/null отдельно от подтверждённого нуля, интервал источника и `atomicSnapshot: false`. +- [x] Перекрывающиеся сборы объединяются; после завершения новый запрос собирает свежие данные. Детерминированный тест удерживает источник и проверяет одну коллекцию для двух читателей. +- [x] SQL COUNT пользователей заменил загрузку всех DTO/привязок для подсчёта; REST сохранил прежнюю форму ответа. SQL-приёмка проверила основные числа, отсутствие native polls при отказе прав/scope/лимита и отсутствие конфигурационных записей. +- [x] Профиль счётчиков включён в `test:all`; полная регрессия 27 профилей + MCP SQL, настоящий pg_dump/restore и SQL logs прошла. Дополнительные модели/build/lint/coverage проверены. [Отчёт](mcp-sidebar-counts-acceptance.json). + +Текущий реестр: 321 инструмент, REST 160/62/40. Полный план остаётся открытым: весь native harness с успешной штатной очисткой, независимые пути/NAT/RPF/hash и три узла, 40 REST-адаптеров, реальный отправитель портала и внешние AI-клиенты. Двухузловая функциональная Community-приёмка и отдельное восстановление приняты ниже. + + +### Допуск Route AI по законченному замеру + +- [x] Успешный control ping требует подтверждённых 3/3; частичный положительный ответ 1/1 или 2/2 не запускает traceroute и не создаёт свежий допуск. Нулевые потери без полных счётчиков не принимаются. +- [x] Unknown содержит фиксированную причину и ограниченные числовые наблюдения, без исходных сообщений устройства; 3/0 остаётся подтверждённой потерей. Ping payload ограничен 256 строками, успешная трасса требует порядка зарегистрированных hops и назначения. +- [x] Реальный SQL/HTTP проверил latest unknown после ранее успешного допуска, incomplete/partial/trace/oversized и отсутствие конфигурационных операций. MCP models, SQL/real restore, build и lint прошли. Полный checkpoint 27 профилей предшествует этой узкой правке; последующая проверка — целевая. [Отчёт](mcp-path-completion-acceptance.json). +- [x] После прерванного GRE-прогона отдельный recovery-only режим удалил только точный маркер f9337137, не создавая новый fixture, и сравнил все 22 исходных fingerprints. [Очистка](mcp-chr-overlay-recovery.json), exit 0; SSH закрыт. Это не закрывает actual Community propagation. + + +### Выгрузки логов для AI-клиента — 21 октября + +- [x] `mm_logs_export_read` / `logs.export.read`: подписанная область подключения/серверов/политики/поколения, metadata до payload, свежие права и grants до расшифрования и после него; legacy/foreign/expiry/tamper отклоняются. +- [x] Чтение частями до 12000 исходных байтов в base64, общий размер/SHA-256/TTL; Unicode восстанавливается после объединения байтов. Секреты маскируются до создания файла, полный chunk не обрезается публичным redactor. +- [x] Общий REST/MCP SQL-источник: server scope до бюджета, максимум 50000 строк/8 MiB до передачи payload в Node, отдельная проверка размера CSV/JSON; REST host-колонка сохранена. Два тяжёлых задания на процесс без очереди. +- [x] Атомарные квоты PostgreSQL: 8 файлов/24 MiB ciphertext на подключение, 256 MiB всего, TTL 15 минут и удаление истёкших файлов при сохранении. Конкурентное создание проверено. +- [x] Реальные SQL-блокировки проверили отзыв/сужение grants без версии, owner/pause/abort во время payload SELECT, а также приоритет отзыва права над ошибкой source budget. Target использует текущий transaction client, read не получает освобождённый client. Общий runtime повторно сравнивает эффективную политику и права владельца перед выдачей результата. +- [x] Все 27 backend-профилей, MCP SQL/HTTP, настоящий pg_dump/restore, SQL logs, build и ESLint прошли. [Принятый объём](mcp-log-artifacts-acceptance.json); native Community остаётся отдельным открытым пунктом. + + +### Native Community: следующий диагностический срез + +21 октября, 02:52–03:02 UTC: свежий underlay прошёл 18/18; GRE/VRF IPv4/IPv6 и BGP prefix forwarding прошли. Полный Community-прогон остановлен на `community-set-matched/ipv4`: после снятия старого активного маршрута и нового approved peer restart положительное состояние маршрута не подтверждено. Причина пока неизвестна. BGP и все 22 исходных overlay fingerprints восстановлены, exit 1. [Неуспешный прогон](mcp-chr-bgp-community-set-unconfirmed.json), [контроль underlay](mcp-chr-underlay-log-artifacts-preflight.json). Прежние отчёты о GRE loss и прерванном unknown сохранены отдельно. Для следующего прогона добавлен ограниченный read-only снимок конкретного префикса из `/routing/route` и owned-сессий до очистки; полный provider payload и секреты не сохраняются. Этот пункт остаётся открытым. + + +21 октября, 03:05–03:15 UTC: диагностический повтор завершён с восстановлением всех 22 fingerprints, exit 1. [RIB и сессии](mcp-chr-bgp-community-unreachable-diagnostic.json) подтверждают получение `bgp.communities=64512:999` на принимающем CHR и установленные IPv4/IPv6-сессии. Причина неположительного допуска локализована до next-hop: `contribution=unreachable`, `gateway=198.18.113.2%mcp-test-94f96471-gre`, `target-scope=10`, разрешённого `immediate-gw` нет. Это подтверждает доставку одного set-атрибута, но не доказывает работоспособность изменения gateway в VRF. Причина неразрешения next-hop и способ её исправления ещё требуют отдельной проверки; table qualification и scope пока гипотезы. Append/clear/blackhole и вся Community-приёмка остаются открытыми. GRE/VRF ping/traceroute до применения политики проходили в обоих направлениях. Fixture удалён; SSH закрыт. + + +### Устойчивость внешней квитанции restore — 21 октября + +- [x] Публикация готовой fsynced-квитанции выдерживает временный Windows sharing denial: повторяется только атомарный rename, максимум 8 попыток/710 мс ожидания для EPERM/EACCES/EBUSY. Старый файл не удаляется; exclusive receipt/.used claims и запрет повторного restore сохранены. Терминальные ошибки и ошибки других платформ не повторяются. +- [x] Реальный Windows-тест держит файл через FileShare.ReadWrite без delete sharing, проверяет читаемость старой квитанции и публикацию новой после освобождения handle. Pure tests проверили границы попыток, сохранность старого файла и terminal errors. В изолированном restore-раннере добавлены безопасные stage/OS-code без SQL/credentials/error body. +- [x] Реальный SQL проверяет отмену старого filter-плана при изменении порядка VRF, в том числе no-op, без записи. Финальные 27 профилей, MCP SQL/HTTP, настоящий pg_dump/restore, tampered archive, SQL logs, build, lint и diff-check прошли. [Отчёт](mcp-state-publication-acceptance.json). + +Прежний единичный RESTORE_UNCONFIRMED после завершения restore не содержал OS-кода; точная причина исторического сбоя не доказана. Контролируемая Windows-блокировка воспроизведена и обработана без повторного восстановления. Два isolated parser-прогона отклонили `set gw IP@VRF` в кавычках и без них; неподдерживаемый прототип удалён из рабочего компилятора. На затронутом CHR baseline восстановлен, второй не изменялся: [String](mcp-chr-filter-vrf-quoted-rejected.json), [Word](mcp-chr-filter-vrf-address-rejected.json). Текущий Community-контроль использует прежний literal gateway без `gw-interface` в той же VRF; исходный сценарий с явным GRE остаётся открытым независимо от результата контроля. + + +### Подтверждённые условия разрешения gateway в VRF + +- [x] На получателе первого CHR RouterOS 7.24.5 явное `set gw` вместе с GRE заработало для IPv4/IPv6 после отдельных human-approved MCP host-route планов: точные gateway `/32` и `/128` в `main`, gateway `owned-GRE@owned-VRF`, `scope=10`, `target-scope=5`. Оба resolver подтверждены active и immediate-gw. Конфликтующий точный main prefix блокирует fixture. Политика продолжает работать в owned VRF; экспорт посторонних connected маршрутов через main не включался. +- [x] Подтверждены set/mismatch/restoration обоих AFI, сохранение другого AFI и добавление новой Community: 7 проверок с 3/3 и 2 проверки подтверждённой потери с 3/0. Host-resolver и все остальные fixture-объекты удалены, все 22 fingerprints восстановлены. [Принятый объём](mcp-vrf-gateway-resolution-acceptance.json). +- [x] Функциональный Community checkpoint принят для IPv4 и IPv6: оба tag после append сохранены из RIB, active/blackhole подтверждены, clear даёт измеренную потерю, после восстановления оба AFI снова передают 3/3. [Отчёт](mcp-bgp-community-dual-afi-acceptance.json) отдельно указывает ошибку штатного teardown и успешное recovery; весь native harness и продуктовая VRF-автоматизация остаются открытыми. +- [ ] Продуктовый Route AI по-прежнему явно отклоняет `profile.table != main`. Native human-managed эксперимент не снимает этот guard: перед VRF-автоматизацией нужны отдельные resolver/dependency/protection/source fences, recovery и полный native сценарий. Исходные требования плана не сокращены. + +Отрицательные контроли: [literal gateway без interface](mcp-chr-bgp-community-literal-gateway-unreachable.json) и [resolver scope10/target10](mcp-chr-bgp-community-resolver-scope10-unreachable.json) не дали положительного маршрута; оба полностью очищены. Дополнительный host resolver устанавливается явным планом, не скрытым побочным действием фильтра. На момент этого resolver checkpoint оставались 52 REST-адаптера; актуальный остаток указан в сводке и последующих блоках. Независимые пути/NAT/RPF/hash, три узла, live portal и внешние AI-клиенты остаются открытыми. + + +### Gateway-зависимости VRF и таблиц — 21 октября + +- [x] MCP запрещает delete/rename/disable VRF и таблицы, если IPv4/IPv6 gateway ссылается на неё из другой таблицы: проверяется точный суффикс `@name`/`@native-ID`, включая `IP%interface@table`, `interface@table` и ECMP. Пробелы в literal names сохраняются; неподтверждённая quoted/escaped форма блокирует изменение. Источник ограничен, unknown не считается отсутствием ссылки. +- [x] Настроенные static-маршруты внутри VRF блокируют её удаление. Собственные kernel dynamic routes внутри этой VRF не считаются внешними пользователями, но остаются в sealed snapshots для проверки дрейфа. Общий interface dependency guard учитывает ту же семантику. +- [x] Реальный MCP SQL проверил обе семьи/name/ID/delete/rename/disable, malformed source и добавление ссылки после approval: старый план отклоняется до записи. Pure models, build, ESLint, diff-check, полный прогон 27 профилей, MCP SQL/HTTP, настоящий pg_dump/restore и SQL logs прошли. [Отчёт](mcp-cross-table-dependency-acceptance.json). +- [x] Прерванный marker `581cb8ad` восстановлен отдельным recovery-only прогоном: только собственные BGP peers/instances/policies и overlay, исходные ID resolver из сохранённого отчёта, неизменные prefix/table/scope и исходный GRE. После прежнего удаления VRF ссылка выглядела как `@*1D`; она разрешалась к очистке только после подтверждения отсутствия объекта и совпадения исходной записи. Все 22 fingerprints совпали, нового fixture не было. [Очистка](mcp-chr-community-login-recovery.json), завершена 07:02:36 UTC, exit 0. + +На момент этого checkpoint полная Community-проверка для обеих семей оставалась отдельным открытым пунктом; последующий функциональный результат приведён ниже. Предыдущий прогон подтвердил IPv4 pipeline и обе семьи set/mismatch, но прервался при отвязке из-за native REST login limit; его отчёт не переписан успешным. Для новых CHR-прогонов интервал запросов задаётся явно; повтор конфигурационных записей не добавлен. + +### Двухузловая функциональная Community-приёмка — 21 октября + +- [x] Получатель подтвердил set/append/clear и восстановление отдельно для IPv4/IPv6, включая сохранение другого AFI. После append непосредственно сохранены оба тега в operational RIB; blackhole требует положительных active/blackhole, точных ID/prefix/table и последующей потери 3/0. +- [x] Приняты 18 Community packet proofs: 12×3/3 с подтверждённой трассой и 6×3/0. Правила отвязаны и own output policy удалена до функционального checkpoint. Использованы отдельные human-approved main host-resolver планы scope10/target5; автоматический Route AI для non-main не включён. +- [x] Исходный harness завершился ошибкой 401 на BGP teardown; зависимость gateway заблокировала удаление VRF. Отдельный recovery-only прогон точного marker d8bddc85 завершился 07:45:22 UTC с кодом 0: все 22 исходных fingerprints совпали, новый fixture не создавался. Оба процесса завершены, SSH закрыт. [Функциональная приёмка](mcp-bgp-community-dual-afi-acceptance.json), [неуспешный harness](mcp-chr-bgp-community-dual-afi-cleanup-interrupted.json), [восстановление](mcp-chr-community-dual-afi-recovery.json). +- [ ] Весь native harness с успешной штатной очисткой, независимые физические пути, NAT/RPF/hash-policy, три узла и продуктовая VRF-автоматизация остаются открытыми. Функциональная приёмка не скрывает сбой teardown. + +### Основные NetFlow-разговоры через MCP — 21 октября + +- [x] `mm_netflow_talkers_get` / `traffic.flow.talkers.read`: общая REST/MCP проекция для `/traffic/flow` и `/traffic/flows`; явная серверная область до SQL и pending, архив отключённых серверов без RouterOS-запросов. До 50000 исходных строк на выбранный набор (распределены между серверами), окно 1–1440 минут, до 10 серверов/200 строк на страницу. +- [x] Сохранены configured topN, отдельные totalConversations/число доступных top-разговоров, точные суммы и стабильная сортировка при равных bytes. Будущие rows исключены и из REST. Суммы разных интерфейсов не считаются дедуплицированным сетевым итогом; bps — среднее по запрошенному окну, coverage и forwarding не подтверждаются. Глобальные host/listener/runtime поля не выдаются. +- [x] Реальный SQL проверил hidden malformed counters до парсинга, foreign pending, grant narrowing, disabled history, topN/страницы, REST parity и отказ на 50001 rows. Блокировка настоящего SELECT проверила source/topN/grant/owner/cancel/pause: результат не выдан. Конфигурационные планы и native polls не создаются. +- [x] Build, ESLint, unit models, все 27 backend-профилей, MCP SQL/HTTP, настоящий pg_dump/restore, tampered archive и SQL logs прошли. Реестр: 309 инструментов, REST 150 mediated / 62 excluded / 50 pending. [Отчёт](mcp-netflow-talkers-acceptance.json). + +### Повтор уведомления с неизвестным результатом через MCP — 21 октября + +- [x] `mm_alerts_outbox_retry_plan` / `alerts.outbox.retry`: общая REST/MCP CAS-операция для unknown/telegram/current resolutionVersion. Обязателен acknowledgedDuplicateRisk=true и отдельное human approval; текст и адресат не задаются нейросетью. Наблюдатель не может подготовить изменение; global allow, весь каталог серверов и protections проверяются до изменения. +- [x] Приватный HMAC связывает план с полной исходной записью, включая payload, адресата и параметры повторов, без раскрытия содержимого в preview/audit. SQL ограничивает источник до 1 MiB до передачи в Node; проверка повторяется под FOR UPDATE непосредственно перед CAS. +- [x] Общий application guard защищает и REST retry. После ожидания row lock заново проверяются текущие права/grants/pause/protections и каталог. Transactional read-back подтверждает только допустимые изменения status/version/nextAttemptAt/lastError; изменение payload/получателя или проигнорированная запись откатываются до commit. +- [x] Подтверждается постановка в очередь: verificationScope=acknowledgement, операция applied/verification=inconclusive, step=acknowledged. Доставка не объявляется успешной. Один idempotency key возвращает прежний результат; два конкурирующих плана не ставят один unknown повторно. Неопределённая операция автоматически не повторяется. +- [x] Приёмка проверила explicit autonomous dangerous grant без обхода human approval, shared REST/CAS, concurrent/idempotent admission, late payload/grant/owner/pause/protection/catalog row-lock гонки, 1 MiB cap и rollback при изменении адресата/ignored write. В тесте используется валидное сообщение, внешний Telegram sender заблокирован и не вызван. Все 27 backend-профилей, MCP SQL/HTTP, настоящий pg_dump/restore, tampered archive, SQL logs, build/lint и diff-check прошли; дополнительный целевой прогон подтвердил автономный gate после полной регрессии без изменения production code. [Отчёт](mcp-alerts-outbox-retry-acceptance.json). Реестр: 310 инструментов, REST 151/62/49. Остальные пункты полного плана не закрываются этой приёмкой. + +### OSPF: общий cost writer и ревизия операции 2 — в работе + +- [x] Расчёт sealed OSPF evidence вынесен в `services/ospf-cost-optimization.ts`; MCP использует общий модуль. REST и MCP cost-only записи используют единый writer с ожидаемым cost, двумя bounded raw GET без per-row disabled getters, полной проверкой остальных native полей и остановкой при ignored/lost/changed результате. Число native rows ограничено 10000. +- [x] Оптимизационный план связывает endpoint/учётные данные изменяемого роутера с приватным HMAC, без раскрытия пароля в preview/audit. Перед чтением/dispatch сверяются актуальные параметры подключения; application/router lock не позволяет обычному server update подменить источник между проверкой и PATCH. OSPF operation revision=2; положительные ревизии ограничены SQL int. Старый план отклоняется при admission, и runner отдельно проверяет ранее queued операции и каждый dispatch. +- [x] Latest build, ESLint, pure writer tests и 13 policy/revision tests прошли. До последней credential/version правки реальный MCP/HTTP SQL подтвердил positive/ignored/opaque drift/lost reply/no replay и настоящий restore. Это более ранняя проверка, она не принимает последние изменения. +- [x] После восстановления лимита штатная проверка разрешений разрешила полный прогон. Все 27 профилей, latest credential/revision/old-queued SQL/HTTP, настоящий pg_dump/restore, tampered archive и SQL logs прошли, exit 0. Прежний сбой review не обходился. [Принятый объём cost writer](mcp-ospf-cost-write-acceptance.json); полный REST adapter и native CHR приёмка этой версии остаются отдельными открытыми пунктами. +- [x] Сервисный REST-адаптер `/servers/:id/ospf/optimize` реализован ниже: общий legacy ranker/apply, human-only полный каталог, source/prefix proofs и отдельный обратный план. Измеряемый optimizer v7 остаётся самостоятельным алгоритмом; сетевые/native gates не закрываются этой SQL/HTTP-приёмкой. +- [x] Дополнительно без сетевого доступа прошли REST-classifier тесты (Fastify inject), private HMAC invariants для host/port/user/password/TLS/server ID и отказ неподписанного server context до GET. Последние SQL/HTTP/queued-version кейсы по-прежнему ожидают полного разрешённого прогона; результаты offline не подменяют его. +- [ ] Аудит полного source graph остаётся открытым: новый HMAC пока связывает изменяемый роутер. Для полного optimizer-подключения нужны также peer endpoint identity и связь измерений с конфигурацией endpoint до момента подготовки; существующий contextVerified сам по себе не доказывает неизменность endpoint после измерения. +- [x] Offline-проверка настоящего реестра actions подтвердила OSPF revision=2 в runtime descriptor и registry; клиент не может передать свою revision или expectedServerProof через публичную схему инструмента. Она включена в test:mcp. SQL-отклонение ранее queued revision=1 остаётся отдельным не выполненным latest кейсом. + +### Замеры маршрутизации: привязка к обоим endpoint и эпохе подключения + +- [x] Миграция `0029_mcp_routing_sources`: DB-trigger меняет эпоху при изменении host/port/user/password/TLS/enabled, включая возврат A→B→A. Изменение имени не меняет эпоху; прямое присваивание старой эпохи её не восстанавливает. +- [x] Каждый новый замер подписывает оба endpoint, поколение доступа, интерфейсы, family/table, метрики, контекст и timestamps с микросекундами. Legacy-строки не подписываются задним числом: история остаётся доступна с `sourceVerified=false`, для оптимизации нужен новый замер. +- [x] Сохранение проверяет доступ и оба endpoint под row locks; INSERT и проверка записанного значения проходят до COMMIT. Игнорирование/искажение записи откатывает транзакцию. Смена источника или отзыв доступа во время ping не публикует пригодный результат. +- [x] Незавершённый ping не выдаёт измеренный ноль. Последняя unknown-попытка блокирует использование предыдущего успеха; ping/BW остаются отдельными потоками. Недоверенная строка блокирует оба потока. Порядок публикации отделён от времени отдельных метрик. +- [x] IPv6 ULA и link-local не создают ложную неоднозначность; при нескольких настроенных адресах доступен `dstAddress`, принадлежащий выбранному интерфейсу peer. Пропущенный disabled разрешается одним bounded getter только после однозначного выбора. Глобальный bandwidth по-прежнему не доказывает WAN-путь. +- [x] OSPF revision=3, diagnostics.bandwidth revision=2. Старые plans/queued операции требуют новой подготовки; изменение peer/primary endpoint делает старые метрики непригодными до PATCH. +- [x] Build/lint/pure/реальные MCP SQL/HTTP/restore прошли. Все 27 профилей прошли суммарно: первый полный прогон выявил только устаревшую отдельную schema теста logs; после применения настоящей миграции повтор MCP SQL/HTTP + restore + logs завершился exit 0. [Backend-приёмка](mcp-routing-source-acceptance.json). +- [ ] Native OSPF/source приёмка не пройдена: обе стороны подтвердили IPv4/IPv6 GRE ping 3/3 и trace, но CHR2 перезапустился до OSPF preflight. Все owned объекты удалены, 14 областей проверены read-only: 10 fingerprints совпали, у двух адресных baseline доказано изменение только dynamic ID; две route baseline остаются не подтверждены. Причина перезапуска не установлена. [Cleanup/диагностика](mcp-chr-ospf-source-cleanup.json). +- [ ] Полный граф уникальности владельцев Router ID, точная общая REST preparation, независимые пути/NAT/RPF/hash/три узла и прочие 49 REST-адаптеров остаются в плане. Реестр остаётся 310 инструментов, REST 151/62/49 из 262. + +Native-прогон текущей OSPF/source версии прервался: оба CHR сначала подтвердили 4 направления/family, по 3/3 ping и полной трассировке; затем ECONNRESET до OSPF preflight и TCP timeout ко второму REST во время очистки. Полный original report сохранён как `mcp-chr-ospf-source-interrupted.json`; это не OSPF-приёмка. Запущен только recovery прежнего marker `mcp-test-c74d0d3c`, без новой сети. Восстановление сверяет все 14 исходных fingerprints этого overlay-профиля; обычный BGP recovery по-прежнему требует свои 22. Source-profile допускает 14 только при точной версии и отсутствии любых OSPF/BFD операций и preflight в исходном завершившемся отчёте. + +### OSPF revision4: весь граф владельцев Router ID — backend принят + +- [x] План связывает endpoints/учётные данные/эпохи/поколение доступа и native OSPF instances всех кандидатов в области MCP-политики, включая устройства, не выбранные владельцами нужного Router ID. Появление нового дубликата, исчезновение instances, смена пароля или отключение такого кандидата отменяет план до PATCH. +- [x] Все кандидаты входят в target и multi-router lease. Лимит serversPerPlan проверяется до native polling. Настоящая advisory-блокировка третьего кандидата проверена в SQL/HTTP. +- [x] Граф повторно сверяется до preflight и при dispatch. Между его native чтениями проверяются действующий доступ и endpoints, после последнего чтения endpoints проверяются снова. Приватные HMAC скрыты в public plan/audit. Граф ограничен 1000 устройствами, суммарно10000 native rows и1МиБ. +- [x] Отключённый опрос приложения не считается доказательством отсутствия роутера в OSPF: неизвестный/отключённый источник блокирует подготовку до native reads. Пустой успешно прочитанный каталог остаётся отдельным подтверждённым состоянием; позднее изменение пустого/невыбранного каталога инвалидирует план. +- [x] Старые revision3 plans/queued операции отклоняются. Истинный реестр runtime/action указывает revision4; клиент не может передать свои proof/version через публичную схему. +- [x] Build/lint, pure модели, SQL/HTTP с третьим роутером, полный прогон27профилей и настоящий pg_dump/restore прошли. Дополнительно реальная смена пароля третьего кандидата внутри HTTP-ответа остановила применение до cost PATCH. [Отчёт](mcp-ospf-owner-graph-acceptance.json). +- [ ] Точный общий REST preparation adapter остаётся pending: прежний UI расчёт использует другие telemetry/model источники. Взаимозаменяемость алгоритмов не утверждается. Необходимо выровнять саму подготовку, проверку источников и health fences для полного REST-сценария. +- [ ] Owner resolver пока консервативно рассматривает все активные instances: повторение Router ID в разных family/VRF требует отдельного контекстного разрешения и приёмки. Граф ревизии4 защищает зафиксированный расчёт, но не закрывает этот функциональный пункт. +- [ ] Native OSPF revision4 не принималась. После перезапуска CHR2 owned объекты удалены; две исходные route baseline всё ещё не подтверждены. Независимые пути/NAT/RPF/hash/три узла/end-user,49 pending REST adapters и внешние интеграции остаются в полном плане. Реестр310инструментов, REST151/62/49 из262. + +### OSPF revision 5: фактические и именованные Router ID + +- [x] Общий reader `ospf-identity-catalog` читает явный Router ID либо разрешает его именованную ссылку через native `/routing/id`. Учитываются static `id`, выбранный `dynamic-id`, disabled/inactive; missing/неоднозначные/неактивные/противоречивые ссылки отклоняются. Пропущенные version/vrf/router-id/flags уточняются через native getter, значения не угадываются. +- [x] Исходная конфигурация instance сохраняется отдельно от effective identity: существующие snapshots не подменяются разрешённым IP. Граф связывает обе формы и состояние используемых ссылок; смена выбранного ID или версии после approval отменяет cost-запись. +- [x] Владельцы выбираются по OSPFv2/v3, а needed Router IDs — только из выбранных локальных instance/interface. Сосед из другого локального VRF не создаёт ложную неоднозначность. Названия VRF у разных устройств не приравниваются. +- [x] Лимит уточняющих getter — 128 на каталог, общие 10000 native rows/1 МиБ сохранены. При применении доступ и source proof проверяются перед каждым native чтением, включая getters. Старые revision 4 plans/queued операции требуют новой подготовки. +- [x] Build/lint/pure, HTTP/SQL matrix literal/static/elected/defaults/late ID/version changes, все 27 профилей и настоящий restore/tamper прошли. Отдельный дополнительный SQL/HTTP+restore сценарий другого локального VRF прошёл. [Приёмка](mcp-ospf-identity-acceptance.json). +- [x] На обоих CHR 7.24.5 подтверждено чтение реального OSPF каталога и фактического `/routing/id`. Реальных OSPF instances сейчас 0/0; разрешение `main` использовало явно синтетический OSPF-потребитель и реальный Router ID source/getters. Native записей 0, туннели закрыты. [Read-only отчёт](mcp-chr-ospf-identity-readonly.json). +- [ ] Полное различение одинакового Router ID между VRF удалённых устройств требует фактического двустороннего соседства/адреса/интерфейса; совпадение имён VRF не является доказательством. Same-version неоднозначность пока отклоняется. Этот пункт не закрывается фильтром версий и локального VRF. +- [x] Exact shared REST optimizer preparation подключён к публичным legacy apply/restore в последующем этапе ниже; UI и эти MCP-команды используют общий legacy ranker. Измеряемый v7 алгоритм остаётся отдельным. Native OSPF/BFD/cost/inverse/end-user приёмка не заменяется SQL/HTTP-проверкой. Две исторические route baseline после перезапуска CHR2 не подтверждены; весь исходный объём плана сохранён. На момент прежнего этапа реестр составлял 310, REST 151/62/49 из 262; актуальные числа приведены в начале плана. + +### OSPF revision 6: фактическая привязка peer к VRF и интерфейсу + +- [x] Владелец выбранного соседа определяется по двустороннему Full: адрес первичного соседа принадлежит peer-интерфейсу, а обратный сосед ведёт к адресу исходного интерфейса и нужному Router ID. Экземпляры сопоставляются по однозначным native ID/name; названия VRF разных устройств не сравниваются на равенство. +- [x] Одинаковый Router ID на изолированном устройстве/в другой VRF не создаёт ложного владельца, если фактические адреса и обратное соседство не совпадают. Два полностью подходящих контекста, в том числе две VRF на одном peer, возвращают `MEASUREMENT_UNKNOWN`. При отсутствии подтверждённого контекста владелец не угадывается. +- [x] Рейтинг использует только метрику с `dstInterface`, совпадающим с разрешённым peer-интерфейсом. Preview содержит peer instance/VRF/interface. IPv6 сравнивается канонически, scope интерфейса/VRF проверяется. Флаги адресов уточняются у устройства; счётчики/возраст adjacency не создают ложный drift. +- [x] Граф всех кандидатов включает effective identities, соседей и адреса, family и private source proofs. Он проверяется перед записью и после записи. Подтверждённый cost с потерей соответствия соседству получает `partial`; повторный ключ возвращает квитанцию без повторного PATCH. Неизвестное чтение/потеря доступа не считается подтверждением успеха. +- [x] Подготовка проверяет актуальные права/временный допуск, отмену и источник между новыми native чтениями/getters. Реальные SQL/HTTP pause/cancel/смена пароля во время ответа останавливают следующий запрос. Лимиты графа 1000 устройств, 10000 строк и 1 МиБ сохранены; уточнение адресных флагов ограничено 128 getters на каталог. Старые revision 5 планы/queued операции отклоняются. +- [x] Полный прогон 27 профилей завершился exit 0. После финального post-write guard и явного кода неоднозначности повторно прошли вся MCP SQL/HTTP-приёмка, настоящий restore/tamper, build/lint и diff check. [Backend-приёмка](mcp-ospf-vrf-link-acceptance.json). +- [x] Read-only проверка на обоих CHR 7.24.5 подтвердила реальные IPv4/IPv6 address sources и flags, OSPF catalogs и named Router ID source. Native записей 0, туннели закрыты. OSPF instances/neighbors сейчас пусты: эта проверка не является live-adjacency/cost/forwarding приёмкой. [Отчёт](mcp-chr-ospf-link-readonly.json). +- [x] Native current-revision OSPFv2/v3/BFD/cost/inverse для IPv4/IPv6 подтверждены отдельной приёмкой 23 октября: [отчёт](mcp-chr-ospf-v6-cost-inverse-acceptance.json). +- [ ] Пересылка по изученным OSPF-маршрутам и полная IPv6-трасса после восстановления остаются открытыми. Для unnumbered/неподтверждаемого адресами линка возвращается unknown; дополнительные режимы требуют отдельной приёмки. Две исторические route baseline после перезапуска CHR2 не подтверждены. +- [ ] Exact shared REST optimizer preparation, три узла/независимые пути/NAT/RPF/hash, внешние клиенты/портал и остальные 49 REST adapters остаются в исходном объёме. Реестр 310, REST 151/62/49 из 262. + +### Обратный OSPF-план и восстановление тестового стенда — 23 октября 2026 + +- [x] Воспроизведена и исправлена ошибка обратной cost-записи: после 30 → 10 обратный план ожидает подтверждённые 10 и восстанавливает 30. Ожидаемая стоимость берётся из зашифрованного снимка после исходного шага. +- [x] Обратный план сохраняет граф источников и двустороннего соседства OSPF со всеми кандидатами. Потеря peer Full перед обратной записью останавливает PATCH. Изменившаяся версия исходного действия требует новой сверки. `operations.recover` повышен до revision 2; старые планы и операции в очереди отклоняются без записи. +- [x] SQL/HTTP проверяет настоящий прямой и обратный PATCH, отказ при изменении peer и версиях, повторный ключ без повтора записи. Все 27 профилей backend и настоящий restore/tamper прошли; после добавления queued-inverse проверки повторно прошёл полный MCP SQL/HTTP/restore. +- [x] Приёмочный сценарий сохраняет только безопасную классификацию ошибок native API, применяет паузы ко всем HTTP-методам и последовательно записывает отчёт. Восстановление прерванного OSPF-стенда проверяет 14 overlay + 8 OSPF/BFD снимков, закрытый исходный отчёт и точный маркер; завершение процесса отдельно проверяется по его handle перед запуском очистки; чужие объекты не удаляются. Проверка этой ветки восстановления пока backend-only. +- [x] Предыдущий native запуск подтвердил четыре соседства OSPFv2/v3 Full+BFD Up, IPv4 signed measurement с защитой ABA и прямую cost-запись. Его обратный план получил unknown; штатная очистка затем подтвердила все 22 исходных снимка. [Неуспешная приёмка](mcp-chr-ospf-v6-inverse-failure.json) сохранена отдельно. +- [x] Native приёмка исправления завершилась 22 октября 18:18:37 UTC, exit 0: 60 применённых MCP-операций, четыре Full/BFD Up, signed source/ABA и cost 30 → 10 → 30 для обоих семейств. Отказ GRE дал 3/0, отдельный одобренный план восстановил 3/3; все 22 исходных снимка совпали, туннель закрыт. Начальные четыре трассы полные; после восстановления IPv6 один hop не ответил при подтверждённом конечном узле. Это контрольные пробы, полная восстановленная IPv6-трасса не заявляется. [Native отчёт](mcp-chr-ospf-v6-cost-inverse-acceptance.json), [сводная приёмка](mcp-ospf-inverse-acceptance.json). +- [x] Пересылка к дополнительным сетям через изученные OSPF-маршруты и разные native VRF подтверждены отдельной revision 7 приёмкой: 16 RIB/packet proofs, cost/inverse/passive обоих семейств и все 24 исходных снимка восстановлены. [Отчёт](mcp-ospf-forward-acceptance.json). Обратный путь контрольных проб — connected GRE; независимые транзитные/пользовательские пути и три узла остаются открытыми. + +### OSPF: пассивные объявления и пересылка в разных VRF (23 октября) + +- [x] Добавлен отдельный режим `MMAPP_TEST_CHR_OSPF_FORWARD=true`: собственные loopback-сети объявляются пассивными OSPFv2/v3 шаблонами; VRF на двух CHR имеют разные имена. IPv6-адреса создаются после назначения интерфейсов VRF. +- [x] Проверка фактического `/routing/route` требует единственный активный OSPF-маршрут нужного семейства/префикса/таблицы, подтверждённую метрику и immediate gateway через тестовый GRE. Неизвестные поля уточняются native getter; неподтверждённые значения не подменяются нулями. Общий ограниченный reader также используется публичным MCP-инструментом `mm_ospf_route_proof_get`; наличие маршрута не считается доказательством пакетной доставки. +- [x] Native RouterOS 7.24.5 возвращает установленный `passive` как пустой switch, а getter отсутствующего switch — пустым массивом. Чтение теперь подтверждает значение через getter либо полное непересекающееся объединение положительного и отрицательного native switch-query, с лимитами и проверкой доступа между чтениями. +- [x] Оптимизатор revision 7 исключает подтверждённые пассивные шаблоны до проверки BFD/ранжирования. SQL/HTTP проверяет stale Full на пассивном GRE и соседство рабочего GRE с пассивным loopback без `use-bfd`; старые планы требуют новой подготовки. +- [x] [Предыдущий native запуск](mcp-chr-ospf-forward-passive-bfd-failure.json) подтвердил четыре активных изученных маршрута с метрикой 60 и четыре пробы 3/3 к дополнительным сетям. Подготовка cost остановилась до записи на ошибочной проверке BFD пассивного шаблона. Все 24 исходных снимка восстановлены; этот отчёт не принимает cost/inverse нового сценария. +- [x] Полная двухузловая приёмка revision 7 завершилась exit 0: IPv4/IPv6 cost/inverse, passive, 16 RIB/packet proofs и штатная очистка с совпадением всех 24 снимков. [Отчёт](mcp-ospf-forward-acceptance.json). Независимые транзитные/обратные пути и три узла этим стендом не доказываются. +- [x] Публичный MCP reader `routing.ospf.route.proof.read` / `mm_ospf_route_proof_get`: явный сервер/префикс/VRF/интерфейс, подтверждённая нулевая метрика, отказ при ambiguous/wrong-egress/unknown, pre-poll scope и disabled, endpoint ABA, pause/cancel и поздний отзыв перед выдачей. SQL/HTTP и настоящий restore прошли; наблюдатель не создаёт планов. Новый реестр содержит 311 инструментов; REST-покрытие 151/62/49 не изменено. Все 27 backend-профилей, SQL/HTTP, настоящий restore, tampered archive, build, types и ESLint прошли. [Приёмка reader](mcp-ospf-route-proof-acceptance.json). + +### Native passive switch и изученные маршруты: результаты повторной приёмки + +- [x] [Отдельно подтверждены](mcp-ospf-forward-cost-acceptance.json) OSPFv2/v3 cost/inverse в разных native VRF: четыре Full/BFD Up, 12 активных изученных RIB-маршрутов с метриками 60 → 40 → 60 на первом узле и неизменными 60 на втором, 12 проб 3/3 с полными трассами, восстановлены все 24 исходных снимка. Полный исходный harness завершился ошибкой на следующем переключении passive; [неуспешный отчёт сохранён](mcp-chr-ospf-v7-forward-passive-toggle-failure.json). Его поздняя ошибка не скрыта и весь harness этим результатом не принимается. +- [x] Исправлены native create/update шаблонов, revision 2: создание с `passive=false` не передаёт switch, состояние подтверждается чтением. Снятие флага использует `/routing/ospf/interface-template/unset`. Смешанное изменение создаёт отдельные шаги PATCH/unset с собственными intent/checkpoint, проверками, лимитами и inverse. Игнорирование unset даёт partial без повтора записи. +- [x] [Короткая native приёмка](mcp-chr-ospf-passive-v2-switch-acceptance.json) завершилась exit 0: 22 MCP-операции, оба семейства, create false → set true → unset false со стоимостью → approved inverse. Использовалась пустая собственная VRF и отключённые bridge/templates без адресов/портов; все шесть исходных снимков восстановлены. Защита отключённого родителя сохранена. Два ранних отклонения тестового сценария и их очистка сохранены отдельно. +- [x] Повтор полного двухузлового harness завершился exit 0: 86 операций, 16 RIB через публичный MCP runtime reader и 16 packet proofs 3/3 с полными трассами; cost/inverse/passive обоих семейств, все 24 исходных снимка совпали, SSH-туннель закрыт. [Native отчёт](mcp-chr-ospf-v7-forward-switch-acceptance.json), [сводная приёмка](mcp-ospf-forward-acceptance.json). Внешний AI-клиент и независимые транзитные пути этим сценарием не принимаются. + +Независимые транзитные/обратные пути, end-user forwarding, три узла, NAT/RPF/hash, точная подготовка legacy REST OSPF, 41 оставшийся REST-адаптер, реальный портал и внешние AI-клиенты остаются в полном плане. + + +Уточнение приёмки 23 октября 2026: + +- [x] OSPF revision 7: на двух CHR приняты cost/inverse и native passive switch для обоих семейств, разные VRF, 16 публичных MCP RIB proofs и 16 контрольных проб с полной трассой; 86 операций, все 24 baseline восстановлены. Обратный путь connected GRE; независимые транзитные и пользовательские пути остаются открытыми. Источник: `mcp-chr-ospf-v7-forward-switch-acceptance.json`. +- [x] Совместимое уточнение BFD отбора revision 7: LAN без Full не блокирует подходящий GRE; неизвестный BFD у Full-кандидата даёт отказ. Проверено SQL/HTTP, моделями и полным backend-прогоном; native LAN-сценарий отдельно не заявляется. +- [x] Общий REST/MCP DELETE одного WireGuard peer, новый человеческий сервисный план и native alias revision 2: привязки/защиты/NetFlow, приватный HMAC видимой конфигурации и источника, единственный DELETE, точный read-back, статус ревизии и read-only desired/original reconciliation без изменения исходного unknown. SQL/HTTP, restore/tamper и 27 backend-профилей прошли. Native: четыре applied операции, один DELETE, отдельная сверка, четыре baseline и приватный отпечаток восстановлены; отключённые fixtures, адреса неизменны, туннель закрыт. Источник: `mcp-wireguard-peer-delete-acceptance.json`. +- [ ] Оставшиеся 40 REST-адаптеров, независимые пути/три узла/NAT/RPF/hash и PSK/SA traffic-приёмка, настоящий portal sender и внешние AI-клиенты остаются в общем плане. Эти успешные приёмки не означают завершение всего плана. + +- [x] Native read-only reconciliation: допуск/отмена/источник проверяются между snapshot/getter и перед выдачей, включая endpoint ABA; источники предыдущих native шагов повторно сверяются перед общей выдачей. Ответ явно ограничен сравнением конфигурации и не удостоверяет исходное устройство/forwarding. Изолированные SQL/HTTP-тесты, 27 backend-профилей, SQL logs, restore/tamper, build/lint прошли: `mcp-native-reconcile-fences-acceptance.json`. + + +- [x] Общий REST/MCP PATCH WireGuard peer: все 11 публичных полей, непустой строгий patch, очистка строк/port/keepalive, alias update revision 2 и человеческое подтверждение. Приватный raw+semantic HMAC, привязки/NetFlow/защиты, один PATCH без повторов, отдельная ревизия и read-only сверка. Native nil keepalive требует getter и полного presence-разбиения, одного отсутствия недостаточно. Проверены malformed/foreign/overlapping receipts, отзыв/отмена между запросами, late drift, partial/unknown и сохранение чужих peers. На CHR пройдены шесть applied операций, два PATCH и очистка с четырьмя baseline/приватным отпечатком; отключённые fixtures, без изменения адресов. Финальные 27 backend-профилей, SQL logs, restore/tamper и build/lint прошли. [Приёмка](mcp-wireguard-peer-update-acceptance.json). Перенос peer между интерфейсами и универсальный service inverse не объявляются реализованными этим PATCH. + +### Общий WireGuard interface DELETE: завершённая приёмка 23 октября + +- [x] Бизнес-план `mm_wireguard_interface_remove_plan` revision 1 и общий REST/MCP сервис: явный порядок/стоимость до 50 команд, disable включённого родителя, peers, статические адреса и интерфейс последним. Native alias delete revision 2 удаляет только пустой интерфейс. +- [x] Защиты пользователей/NetFlow/родителя/детей, protocol/IP/OSPF selectors и escaped filter dependencies; приватные prefix proofs, fences и read-back каждой команды. Partial/unknown не повторяют запись; read-only prefix reconciliation сохраняет исходный статус, универсальный inverse отсутствует. +- [x] SQL/HTTP проверил normal/ignored/collateral/lost/revoke, ранние отказы и отсутствие replay. Полный прогон 27 профилей, logs и real restore/tamper прошёл; после финального UTF-8 grouping отдельно прошли SQL core/restore, модель, build и lint. +- [x] Native CHR: шесть applied операций, пять DELETE, отказ alias от каскада, восстановлены четыре baseline и приватный отпечаток; туннель закрыт. [Сводный отчёт](mcp-wireguard-interface-delete-acceptance.json), [неизменяемый native протокол](mcp-chr-wireguard-interface-delete-v1-acceptance.json). Это конфигурационная приёмка; forwarding/внешний клиент не заявляются. +- [ ] Остаток полного плана: 40 REST-адаптеров, независимые/end-user/трёхузловые пути, NAT/RPF/hash и PSK/SA traffic, реальный отправитель portal и внешние AI-клиенты. Исторические два route hashes прерванного OSPF fixture остаются неподтверждёнными; новые успешные baseline не заменяют их проверку. + +### NetFlow analytics: завершённый сервисный адаптер + +- [x] `traffic.flow.analytics.read` / `mm_netflow_analytics_report_get`: общий REST analytics engine с серверной областью до SQL/pending, без общего кэша и скрытой топологии; приложения/протоколы/IP/интерфейсы/ASN/страны/категории/services/conversations/inferred paths, filters/dedup и страницы. +- [x] Ограничения строк/страниц/байтов и явные topN; только публичный SQL-лимит без ключей коллектора, будущие строки исключены. Mixed hidden bindings исключены, peer-only интерфейсы не атрибутируются пользователю целиком. Доступ/источник проверяются между чтениями, topology/topN — повторно перед выдачей. Context rates и предположительные пути явно ограничены в смысле доказательств. +- [x] SQL/runtime проверил фильтры/дедупликацию, кэш, scope/privacy, bounds/pages, endpoint ABA/topology drift и cancel/pause, отсутствие планов. Все 27 backend-профилей, SQL logs, настоящий restore/tamper, build/lint прошли. [Приёмка](mcp-flow-analytics-acceptance.json). Выделенный REST HTTP вызов и native/AI-клиент в этом сценарии не заявляются. +- [ ] Полный план открыт: 40 REST-адаптеров и ранее перечисленные routing/traffic/three-node/portal/external-client gates. Создание WireGuard с приватными ключами и составными адресами/peers требует полного общего сервиса; упрощённый native create не считается этим REST-адаптером. + +### NetFlow map-hops: завершённый сервисный адаптер + +- [x] `traffic.flow.map.read` / `mm_netflow_map_hops_get`: общий REST map engine, явная серверная область до SQL/pending, scoped topology без скрытых пользователей/серверов и peer-only атрибуции, обход общего кэша. Основные и вложенные страницы включая edge clients и country service paths; неполный граф отмечен явно. +- [x] Публичные topN/порог без ключей коллектора, пределы строк/ответа, future bound; доступ и источник между чтениями, топология и настройки повторно перед выдачей. Предположительные связи не объявляются traceroute/forwarding proof; native poll, enrichment и запись конфигурации не запускаются. +- [x] SQL/MCP runtime и настоящий REST маршрут через Fastify.inject подтвердили совместимость totals/services/hops; scope/privacy/cache, nested pages/filters/dedup/pending/future/row limit, endpoint ABA/topology/threshold drift и cancel/pause, отсутствие планов. Все 27 backend-профилей, SQL logs, real restore/tamper, build и ESLint прошли. [Приёмка](mcp-flow-map-acceptance.json). +- [ ] Полный план открыт: 40 REST-адаптеров, независимые/end-user/трёхузловые пути, NAT/RPF/hash и PSK/SA traffic, реальный portal sender и внешние AI-клиенты. Исторические два route hashes не подтверждены новыми успешными конфигурационными сценариями. + +### NetFlow host-files: завершённый сервисный адаптер + +- [x] `traffic.flow.hostFiles.export` / `mm_netflow_host_files_export_plan`: общий REST builder, полный global/server scope до чтения ключей, человеческое подтверждение, зашифрованный owner-only пакет на 15 минут. MCP/audit не содержат ключ; установка не выполняется. +- [x] Подписанные purpose/header/credential/policy/generation/source/hashes, лимиты и общая квота; read-back шифротекста и источника. JWT владельца и traffic-write обязательны для download; отзыв, stale source и cross-purpose подмена под логи блокируют plaintext. +- [x] SQL/runtime и фактический JWT HTTP/REST проверили основные положительные/отрицательные сценарии. Все 27 профилей и SQL logs/restore/tamper прошли до финального уточнения verifier; финальные целевые HTTP/SQL/restore завершились по журналу, их exit code не наблюдался. Build/lint/coverage успешны. [Протокол](mcp-host-files-acceptance.json). +- [ ] Полный план открыт: 40 REST-адаптеров и ранее перечисленные независимые/трёхузловые/traffic/portal/external-client gates. Экспорт не является установкой коллектора или доказательством forwarding. + +### Telegram settings: завершённый сервисный адаптер + +- [x] `alerts.telegram.update` / `mm_alerts_telegram_update_plan`: settings chatId/thread и owner/purpose secretRef вместо сырого токена; full global/server scope, человеческое подтверждение, строгие поля и preserve/clear semantics. +- [x] Приватные state/reference HMAC и updatedAt, проверки перед записью, общий application lock/guarded REST selective-update и фактический read-back. Защищённые параметры REST не обходит; токен не отражается в результате или аудите. +- [x] SQL/runtime/REST проверили positive/negative cases; полный прогон 27 профилей, SQL logs, real restore/tamper, build/lint прошёл. [Протокол](mcp-telegram-settings-acceptance.json). Текущий полный прогон также подтверждает финальные host-files read-back и JWT/cross-purpose HTTP после уточнений прошлого этапа. +- [x] POST `/api/alerts/telegram/test` реализован следующим этапом ниже: подтверждаемый единственный вызов, private proof/late dispatch gate, provider rejection/unknown без повтора. Проверен локальный HTTP; реальные сообщения Telegram не отправлялись. +- [ ] Полный план открыт: 40 REST-адаптеров и остальные ранее записанные routing/traffic/three-node/portal/external-client gates. + +### Telegram test: завершённый сервисный адаптер + +- [x] `alerts.telegram.test` / `mm_alerts_telegram_test_plan`: human-only/global/full scope, stored/ref overrides без сохранения, точный preview адресата/темы/текста и private source/reference proof перед dispatch. +- [x] Общий REST builder и protected REST send guard; один POST, timeout 30s, response cap 64 КиБ, redirect/error и ноль retry. API acknowledgement не объявляется доставкой. Explicit rejection сохраняется отдельно; unknown не повторяется автоматически, idempotent apply не отправляет заново. +- [x] Локальный HTTP/SQL/REST доказал основные ветви, поздний drift до первого POST, токены не раскрываются. Все 27 профилей/SQL logs/restore/tamper прошли до финального preview fence; после него отдельно прошли SQL/HTTP/restore, build/lint. [Протокол](mcp-telegram-test-acceptance.json). Внешние Telegram сообщения не отправлялись. +- [ ] Полный план открыт: 40 REST-адаптеров, независимые/end-user/трёхузловые пути, NAT/RPF/hash/PSK-SA traffic, настоящий portal sender и внешние AI-клиенты. + +### Legacy REST OSPF: общая подготовка и последовательное применение — в работе + +- [x] Выделен `ospf-legacy-optimization.ts`: `prepareLegacyOspfOptimization` сохраняет исходный rank provider, pingWeight и порядок; REST preview/apply используют одну подготовку. Последовательный apply принимает проверку перед каждой записью и передаёт подтверждённый prefix; отказ останавливает следующие команды, повтора нет. REST JSON и состояния rejected/partial/unknown сохранены. +- [x] Существующие REST HTTP-проверки, профиль OSPF и новая модель prefix-gates/no-op/source refusal прошли. SQL/HTTP-потребители preview, настоящий restore/tamper, build/lint прошли. [Прогресс](mcp-ospf-legacy-shared-progress.json). После восстановления лимита повторно прошла полная регрессия всех 27 backend-профилей, SQL logs и настоящий restore; подробнее в отчёте ниже. +- [x] Legacy apply подключён к MCP в последующем этапе ниже; shared preparation и исходный REST-рейтинг сохранены. Исторический прогресс этого раздела не являлся завершённым адаптером. + +### Повторная проверка после восстановления лимита — 23.10.2026 + +- [x] Полная SQL-регрессия завершилась с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий `pg_dump`/`pg_restore`, проверки tamper. Backend build и адресный ESLint текущих legacy OSPF файлов также прошли. [Отчёт](mcp-restored-limit-checks.json). +- [ ] Повторная CHR-приёмка OSPF остановлена до оптимизации: IPv4 GRE control proof прошёл, IPv6 получил 2 из 3 пакетов (`PING_PARTIAL_LOSS`, `unknown`). Маркер `mcp-test-19b53dc9`, код процесса 1. Причина потери пакета не установлена; успешной OSPF-приёмкой этот запуск не считается. +- [x] После отказа удалены только объекты этого запуска; все 16 исходных отпечатков конфигурации двух CHR восстановлены. SSH-туннели закрыты. Повтор мутаций после отказа не выполнялся. +- [ ] Полный план и CHR-приёмка legacy MCP apply остаются открыты; два исторических route hash запуска `c74d0d3c` всё ещё не доказаны. Предыдущая успешная OSPF v7 приёмка сохранена отдельно и не заменяет результат текущего запуска. + +### Legacy OSPF: приватные источники и проверка последовательного cost-prefix — в работе + +- [x] `readLegacyOspfSealedSources` использует настоящий REST ranker и общий preparation. Собирает keyed HMAC для использованных SQL-источников, native OSPF-конфигурации, getter/switch-query ответов и итогового рейтинга. SQL/native строки и credentials не возвращаются в proof. +- [x] Проверяется полный набор чтений pipeline. Native uptime соседей и неиспользуемые legacy алгоритмом runtime BFD/RIB не выдаются за configuration proof. Порядок соседей сохраняется, поскольку он влияет на разрешение равных состояний; opaque поля конфигурации остаются запечатаны. +- [x] При повторном расчёте допускаются только cost подтверждённого последовательного prefix. Преждевременное изменение следующего cost, исчезновение строки, SQL drift и изменение других полей отвергаются либо меняют proof. Полный каталог владельцев приложения ещё должен проверять вызывающий MCP-адаптер: выбранная область peers сама этого не доказывает. +- [x] До/после SQL и native чтений выполняется access callback; есть отмена и срок не более 45 секунд. Native frame ограничен 1 МиБ/10 000 строк, весь сбор — 8 МиБ/512 наблюдений/256 ключей. SQL-выборки для proof ограничены с sentinel-строкой: превышение 2000 metric rows или 1000 catalog rows приводит к отказу, а не усечённому результату. +- [x] Финальный OSPF-профиль и SQL/HTTP/restore/tamper прошли с кодом 0. Реальная SQL/HTTP-фикстура проверила точное REST ranking parity, подтверждённый cost-prefix, отказ при неправильном prefix, реальный SQL drift, отмену, deadline и отзыв до чтения. Build и адресный ESLint также прошли. [Прогресс и source hashes](mcp-ospf-legacy-sources-progress.json). +- [x] Proof, полный scope, protections, dispatch и recovery подключены к публичным планам в этапе ниже. Исторический этап collector проверял только внутренний сбор источников. + +### Legacy OSPF: внутренний исполнитель и проверка после write intent — в работе + +- [x] `MikrotikClient.patch` принимает внутренний `beforeDispatch`, который выполняется внутри общего guarded dispatch после durable `configuration.intent`, до сетевого запроса. Cost writer передаёт сюда проверку источников; отказ маскирует детали и сохраняет `writeAttempted=false`. No-op callback не вызывает. CHR instrumentation передаёт callback дальше и не теряет проверку. +- [x] `executeSealedLegacyOspf` использует общий legacy apply, application lock и отсортированные router locks. Полный proof проверяется перед шагом, непосредственно перед PATCH и после native read-back. Неопределённый ответ или изменение контекста останавливают следующие записи без retry; наружу выходят только безопасные состояния и подтверждённый prefix. +- [x] `reconcileSealedLegacyOspf` выполняет только чтения: различает исходное состояние, конечное состояние, подтверждённый промежуточный cost-prefix и неподтверждённое изменение конфигурации. Проверяется metadata/credential/endpoint epoch источника перед каждым native чтением и перед публикацией; stale client не делает сетевой запрос. +- [x] SQL/HTTP-фикстура проверила две последовательные записи cost, SQL drift после durable intent с нулём PATCH, stale отказ до записи, ignored/lost/collateral остановку после одного PATCH и сверку без повторной мутации. Полная регрессия **27 профилей**, SQL `/logs`, настоящий restore/tamper, build и адресный ESLint завершились с кодом 0. Первый SQL-прогон остановился на отсутствовавшем импорте новой фикстуры; исправление включено в финальную полную регрессию. [Отчёт](mcp-ospf-legacy-execution-progress.json). +- [x] Публичный MCP legacy apply и отдельный human restore зарегистрированы и приняты SQL/HTTP-прогоном ниже. Весь исходный план и CHR-приёмка нового сценария остаются открыты. + +### Публичные legacy OSPF MCP apply/restore — SQL/HTTP этап завершён + +- [x] `routing.ospf.application.optimize` / `mm_ospf_application_optimize_plan` и `routing.ospf.application.restore` / `mm_ospf_application_restore_plan` v1: человеческое подтверждение, весь каталог до 10 серверов, строгие args, до 50 cost-записей, общие REST ranker/apply, private source/native/catalog/protection proofs и свежая проверка подключения перед native read. +- [x] Live owner/policy/protection authorization до/после чтений и после durable intent перед PATCH. Структурный preview показывает конкретные cost, receipts разделяют nativeWrites и сервисные шаги; pause/deny после intent дают ноль PATCH и не объявляются успехом. +- [x] Readonly reconciliation и отдельный human restore исходных cost. Обратные цели отделены от вычисленных optimalCost: тест меняет метрику и рейтинг, но восстанавливает исходные значения. После lost-response допускается только новый обратный план после подтверждения native-prefix; collateral drift запрещает восстановление. Повторов mutation нет. +- [x] Полная регрессия 27 профилей, SQL `/logs`, restore/tamper, build/lint прошли. Покрытие: **321 инструмент; 160 mediated / 62 excluded / 40 pending из 262 REST routes**. [Приёмка и source hashes](mcp-ospf-legacy-actions-acceptance.json). Первая попытка public fixture ошибочно ожидала worker-state при раннем admission deny; исправлено ожидание теста, защита сохранена. +- [x] Конфигурационная приёмка этих двух команд на обоих CHR принята в следующем этапе ниже. Измеряемый v7 optimizer остаётся другим алгоритмом. +- [ ] Прежняя IPv6 GRE-попытка 2/3 не закрыта конфигурационным сценарием. Весь native harness, независимые/end-user/трёхузловые пути, NAT/RPF/hash/PSK-SA, 40 REST-адаптеров, реальный portal sender и внешние AI-клиенты остаются в исходном плане. Два исторических route hash `c74d0d3c` всё ещё не доказаны. + +### Legacy OSPF: новые публичные apply/restore приняты на обоих CHR + +- [x] Новый изолированный профиль `--chr-ospf-legacy`: allowlist двух CHR/admin, временный PostgreSQL, пароль только через скрытый terminal prompt. Чужие OSPF templates/соседи блокируют запуск до первой записи. Создаются только уникальные disabled bridge без портов/адресов, отдельная VRF и passive OSPF templates; все изменения через подтверждённые MCP-планы. +- [x] На каждом CHR выполнен цикл `30/40 → 10/20 → 30/40` через публичные legacy optimize/restore; read-only reconcile не добавил PATCH. Суммарно восемь cost PATCH и 32 подтверждённые MCP-операции, все 24 исходных fingerprints восстановлены, объекты удалены, SSH-туннели закрыты. Native процесс завершился с кодом 0. [Протокол](mcp-chr-ospf-legacy-d875e896-acceptance.json), [аудит](mcp-chr-ospf-legacy-audit.json). +- [x] Backend build и адресный ESLint нового профиля/harness прошли. Негативная проверка запрета нескольких CHR-режимов прошла до подключения. Производственные source-файлы после полной регрессии 27 профилей публичного legacy OSPF не менялись; новый профиль принят отдельным реальным запуском. +- [ ] Этот этап подтверждает конфигурацию и восстановление, а не forwarding/Full/BFD/независимое управление/три узла. Прежний IPv6 GRE preflight 2/3 и два исторических route hash остаются открыты. Счётчики 321/160/62/40 не меняются; весь оставшийся исходный объём плана сохранён. + +### GRE/OSPF: ограниченная read-only готовность стенда — в работе + +- [x] Начальный bring-up требует две последовательные полные 3/3 path proofs за максимум четыре наблюдения. Только начальная измеренная partial loss допускает новое чтение; её исходный unknown сохраняется. Полный отказ, другой unknown, malformed/неполная трасса и транспортная ошибка не повторяются. Production admission и post-fault/recovery/cost assertions не менялись, конфигурационные команды не повторяются. +- [x] IPv6-диагностика ограничена собственными адресами/GRE и neighbor cache, bounded rows/bytes, неизвестные flags null; raw keys/MAC/чужие строки не возвращаются. Причина потери пакетов не объявляется доказанной по состоянию cache. +- [x] Локальные модели, build/lint, SQL/HTTP/restore и выбранный профиль auth/MCP плюс SQL logs прошли с кодом 0. Это выбранный профиль, а не новый полный прогон всех 27. [Отчёт](mcp-overlay-readiness-progress.json). +- [ ] Реальная readiness ещё не принята: запуск `d33a0265` остановлен до проб/OSPF из-за HTTP 401 на GET `/ipv6/address`; bridge update отклонён до записи. После cleanup все 16 fingerprints совпали. Восемь последующих read-only GET дали HTTP 200, причина 401 не доказана. Настройки RouterOS и активные сессии не менялись; SSH-туннели закрыты. Отчёты обеих неуспешных попыток сохранены, прежняя IPv6 2/3 не подменяется новым сценарием. +- [ ] Весь исходный объём, включая 40 REST-адаптеров, пакетную/независимую/end-user/трёхузловую/NAT/RPF/hash/PSK-SA приёмку, настоящий portal sender, внешние AI-клиенты и два исторических route hash, остаётся в плане. + +### Повтор проверок после восстановления лимита — 23 октября 2026 + +- [x] Новый полный прогон завершился с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий `pg_dump`/`pg_restore` и проверки целостности. Backend build и адресный ESLint также прошли. [Итог и source hashes](mcp-limit-recheck-acceptance.json). +- [x] На обоих CHR прошли начальные GRE IPv4/IPv6 ping/traceroute, OSPFv2/v3 Full и BFD Up. Восемь проверок RIB и восемь пакетных проб 3/3 с полной трассой подтвердили baseline обеих AFI и IPv4 после изменения cost и обратного плана. Это control probes с connected GRE return, независимость путей не доказана. +- [ ] Полная CHR-приёмка не завершена: следующий optimizer получил `unknown`/`DEVICE_UNAVAILABLE`, GET `/routing/ospf/instance` вернул HTTP 401. Причина 401 не установлена, конфигурационные команды автоматически не повторялись. [Неизменяемый протокол отказа](mcp-chr-ospf-forward-7aba91f9-auth-failure.json). +- [x] Первичная очистка также встретила отказ. Отдельная очистка по завершённому marker-bound протоколу через новые подтверждённые MCP-планы закончилась с кодом 0: все 24 исходных отпечатка восстановлены, объекты удалены, SSH-туннели закрыты. [Протокол восстановления](mcp-chr-ospf-forward-7aba91f9-recovery.json). +- [x] Общий GRE revision restore и публичная MCP-регистрация завершены и приняты локально в следующем этапе. CHR-приёмка нового адаптера остаётся открытой; два исторических route hash `c74d0d3c` остаются недоказанными. + +### GRE revision restore: общий REST/MCP-исполнитель — локально принят + +- [x] `mm_gre_revision_restore_plan` / `network.gre.revision.restore` v1 вызывают тот же `restoreGreRevision`, что и `POST /api/gre/revisions/:id/restore`. Проверяются scope, section, fingerprint, каноническая полная схема и размер ревизии; повреждённый payload не превращается в удаление всего раздела. +- [x] Восстанавливаются весь раздел GRE и статические IPv4/IPv6 адреса. Удаление используемого туннеля запрещено. Все затронутые GRE создаются/переводятся в disabled до адресных команд, включение выполняется последним. Сохраняются неприменяемые opaque/foreign поля и точные пробелы PSK. Старый пустой/маскированный ключ сохраняет подтверждённый текущий; новый туннель с неизвестным ключом запрещён. +- [x] Human approval обязателен и в autonomous policy; observer не читает источники restore. Бюджет резервирует весь пакет и запись истории. Публичный review содержит имена, удаления, адреса, MTU и enabled; ключи и комментарии туда не попадают, материализованный snapshot зашифрован. +- [x] Полные proof, зависимости, защиты, источники и допуск проверяются между командами и после durable intent непосредственно перед PUT/PATCH/DELETE. Для каждого шага есть read-back. Ignored/collateral/late-binding дают partial, потеря ответа — unknown; повторов нет. Ограничения: 50 туннелей/адресов/native-команд, 1 МиБ ревизии/наблюдения, 8 МиБ на сбор, 45 секунд на чтение и 120 секунд на выполнение. +- [x] Read-only reconcile сравнивает исходный proof или проверенный конечный receipt. При отсутствии конечного checkpoint успех не выводится из одного желаемого payload. Удаление старой исходной ревизии ротацией после успешной записи не ломает verification. Новый restore требует нового человеческого плана; автоматический обратный service-план не заявляется. +- [x] Модели, отдельный SQL/HTTP-профиль с настоящим restore, общая SQL/HTTP-приёмка, все 27 backend-профилей, SQL `/logs`, настоящий dump/restore и tamper завершились с кодом 0. Build, ESLint и diff check прошли. [Отчёт и source hashes](mcp-gre-revision-restore-acceptance.json). +- [ ] Реальная CHR-приёмка нового whole-section restore выполнялась, но пока не принята: [текущий протокол](mcp-chr-gre-revision-acceptance.json). Не подтверждаются IPv6 readiness, IPsec SA и forwarding. Полный план остаётся открытым: **322 инструмента; 161 mediated / 62 excluded / 39 pending**, остальные сетевые/portal/external-client gates и два исторических route hash сохранены. + +## История промежуточных приёмок + +Ниже сохранены прежние отчёты. Текущий статус определяется реестром в начале документа; исторические counters и закрытый расширенный speed-проект не являются актуальным остатком. + +**29 октября — подготовлен фиксированный key/HMAC-примитив для OP-M4.** [Локальный отчёт](mcp-node-key-effect-progress-20261029.json): 42 точечные проверки, независимый review, полная peer-регрессия, build и строгая проверка типов прошли. Чтение ровно 32 байт и HMAC выполняются без `await`; проверяются текущий hash/inode, исходные actor/owner signals и wall/monotonic deadline, scratch key очищается до close. Примитив пока не подключён к dispatcher: он не удостоверяет Q/права/подписанный источник и не сокращает старую цепочку проверок. Полная same-owner цепочка, measured-latency positive и реальное окно ещё открыты. **OP-M: 3/4, S3: 5/10.** Новых процессов/pool/миграций нет; оставшееся окно стенда сохранено, нагрузочная приёмка всей установки ≤2 GB не завершена. + +**29 октября — OP-M3 принят как компонент, готово 3/4 шагов окна.** [Guarded managed/external sources](mcp-guarded-peer-sources-acceptance-20261029.json): 57 focused tests, 9 actual PG/localhost групп, 24 legacy PG-группы, native 77/77; полные peer/storage-наборы и build прошли. External metadata работает без key file и проверяет current encrypted profile/generation/directory; оба sources требуют genuine original-window guard. Реальный race HELD → потеря owner во время Q воспроизведён и исправлен через непрерывный lifetimeSignal connector → core → node → window. Последний внутренний шаг **OP-M4 открыт**: fixed effects на том же PID, положительная measured-latency и эксплуатационная приёмка. **S3: 5/10 закрыто, 5 осталось.** Окно стенда не использовано; текущий [operating-срез](mcp-operating-progress-20261029.json) сохраняет полный план и требование всей установки ≤2 GB. + +**28 октября — managed metadata OP-M3 принят как компонент.** [29 новых тестов и 4 actual PG/localhost-сценария](mcp-operator-peer-metadata-managed-acceptance-20261028.json): genuine observation/prerequisite и текущий signed grant проверяются без повторной выдачи credentials, Q или native. SQL сравнивает исходные private endpoint/password внутри PostgreSQL и возвращает boolean; неверные значения отклоняются даже при неизменном epoch. Штатный endpoint trigger сохранён: восстановление полей не оживляет старый origin. Build и полный peer-набор прошли. **External provider пока отсутствует и отказывает без fallback, OP-M3 открыт.** Готово по-прежнему 2/4 внутренних шага окна; S3 **5/10 закрыто, 5 осталось**. Одно окно стенда сохранено; весь MCP-план и ≤2 GB RAM остаются открыты. + +**28 октября — same-lease metadata принят как компонент.** [19 новых тестов и 7 actual PostgreSQL-сценариев](mcp-public-registry-metadata-acceptance-20261028.json) подтвердили работу без secret files, исходные role/catalog/source validators, точный PID/exclusive lock, отказ конкурента и физическое закрытие при abort/busy/drift. Дополнительные pool/роль/миграция не созданы. Build и полный peer-набор прошли. В остатке operator window теперь **2 из 4 внутренних шагов готовы**: OP-M1 public files и OP-M2 public same-lease inspection; OP-M3 private endpoint metadata и OP-M4 fixed effects/latency/stand acceptance открыты. Это не закрывает S3-06: **основной счётчик 5/10, осталось 5**, одно окно стенда сохранено; весь MCP-план и ≤2 GB RAM открыты. + +**28 октября — public metadata prerequisite реализован.** [17/17 новых тестов и актуальная регрессия](mcp-operator-public-metadata-progress-20261028.json): core/node читают только свежие подписанные публичные файлы, не получают ключи/credentials или SQL lease и не создают strong authority. Настоящая configured core → node композиция v1/v2 проверена; backend build и полный peer-набор прошли. Это завершённый внутренний шаг S3-06, а не готовое operator window. Далее нужны fixed same-lease metadata session, private current endpoint/credential commitment proof и fixed effect dispatcher с измеренной latency-приёмкой. [Точный effect ledger](bandwidth-operator-effect-ledger.md) сохраняет обе HMAC-фазы и реальные before/after границы. **S3: закрыто 5/10, осталось 5; одно разрешённое окно сохранено, общий MCP-план и ≤2 GB RAM открыты.** + +**28 октября — текущий operating/performance-срез.** Диагностический v7 прошёл получение Docker quiescence и открытие source, затем отказал в registry SQL; точная SQL-причина не установлена. Независимый watchdog восстановил исходные policy/running и health 200. Последующий read-only postflight подтвердил четыре исходных контейнера и отсутствие всех 17 временных путей. Из двух дополнительно разрешённых окон использовано одно; **одно осталось и не расходуется до положительной локальной проверки с измеренными задержками Docker**. Полный пятиконтейнерный scan оценён в 464,748 ms; прежние 308 scans требуют около 143 секунд при неизменном допуске 5 секунд. Объединение парных inspect/authorize и параллельное чтение публичных файлов прошли unit-проверки, но производительность композиции ещё не принята: realistic-latency профиль отказал до credentials/COMMIT, без действий с backend. Последние backend build и полный peer-набор прошли. [Текущий отчёт](mcp-2gb-operating-progress-20261028.json), [непринятый проект изменения контракта](bandwidth-operator-metadata-effect-contract.md). **S3 остаётся 5/10; полный план и требование всей установки ≤2 GB сохраняются.** + +**28 октября — следующий компонентный срез и ограничение 2 GB.** [Отчёт](mcp-writer-speed-progress-20261028.json): genuine MCP speed прошёл 6 owned SQL/HTTP-групп (4 bandwidth POST, 3 after-ping, 3 истории), writer/kernel/fixed native transport — 31 группу, native-регрессия — 77 тестов. Публичный speed action, все writers/callers и production mapping ещё не включены. Два однократно разрешённых operating-прогона завершились отказом; второй не имеет доказанной причины. Read-only postflight подтвердил четыре исходных контейнера и отсутствие временных путей. **Вся установка должна укладываться в 2 GB RAM:** [ресурсная приёмка](mcp-2gb-resource-acceptance.md) добавлена в план. Нынешние примерно 27 тысяч application SQL на один MCP speed требуют оптимизации; приёмка производительности не закрыта. **Счётчик остаётся 5/10**, полный исходный план сохраняется. + +**2026-10-28 — следующий принятый интеграционный пакет.** [Отчёт](mcp-speed-integration-progress-20261028.json): постоянный router guard прошёл 18 SQL/HTTP-сценариев и same-control compatibility с v1/v2; genuine scheduler provider — 6 SQL/TCP-групп; REST provider — 7 групп, включая probe race до credentials. Реализован MCP full-speed original с private lifecycle (14 unit) и общий workflow трёх original (41 workflow/run-identity unit). Общий control journal блокирует повторный/concurrent/cross-caller run ID до native: 6 baseline + 4 run-ID actual SQL/HTTP-сценария приняты. Native 70 и общие проверки прошли. **Счётчик этапа остаётся 5/10**: эксплуатационный operator-window, все writers/held mapping, все callers, публичные MCP plan/receipt и полный положительный MCP SQL/HTTP, итоговая UI/CHR-приёмка ещё нужны. [Конкретные точки интеграции guard](router-configuration-guard.md), [актуальный трекер](mcp-speed-progress.md). Полный план не сокращён и не завершён. + +**2026-10-28 — принята сквозная owned-приёмка общего speed workflow.** [Отчёт](mcp-speed-workflow-acceptance-20261028.json): 6/6 сценариев, 6 bandwidth POST, 5 after-ping и 5 реальных COMMIT истории через genuine REST/JWT, signed control source, execution host и приватную completion receipt. Нулевые результаты, изменение/удаление пробы, потеря возврата COMMIT и native socket drop проверены; `unknown` не вызывает повторной отправки или второй failure-записи. Scheduled speed provider и полный общий workflow реализованы; runtime callers и публичный MCP speed action ещё не включены. Quiescence/wire mapping этого профиля синтетические, CHR и рабочая БД не использовались. Текущий этап остаётся **5/10**: held mapping/writer fence, действующее operator-window, durable run-ID conflicts/replay, все callers и финальная UI/CHR-приёмка открыты. Полный исходный объём сохраняется в [реестре](mcp-remaining-work-20261026.md), измерения и границы — в [трекере S3](mcp-speed-progress.md). + +**Прогресс текущего speed-test этапа на 28 октября: 5/10 закрыто, 5 осталось.** [Трекер с постоянными ID](mcp-speed-progress.md) отделяет принятые компоненты от production-интеграции. [Актуальная scoped SQL/HTTP-приёмка](mcp-bandwidth-native-window-acceptance-20261028.json): v1 — 25 сценариев с dump/restore; повтор v2 — 23 сценария, 89 license GET и 2 btest POST. Настоящие SQL/transport barrier и operator writer lifecycle приняты как компоненты S3-06; действующий внешний stop/restart issuer, independent mapping, all-caller/public speed integration и финальные gates ещё открыты. Остальные разделы полного плана сохраняются. + +**Повтор после восстановления сервиса разрешений 28 октября прошёл.** Native — 57/57, полный peer script — exit 0 (108 aggregate, 26 mock boundary и 9 network tests), общая MCP-регрессия — exit 0. Прежние v1 timeout и v2 enrollment failure сняты реальными owned PostgreSQL прогонами, TTL 5 секунд и guards сохранены. Объединённые core/registry validators, свежие file checks и пакет MAC сокращают повторный I/O без продления полномочий. Внешний quiescence в SQL-профиле остаётся явно синтетическим; глобальная production-активация и полный план не завершены. [Непринятый срез 26 октября](mcp-bandwidth-resumed-checks-20261026.json) сохраняется как исторический. + +**28 октября — добавлены и приняты следующие компоненты S3-06/07/08.** Optional [Docker-only stop adapter и consumer](bandwidth-operator-docker-stop.md): 22 synthetic tests; operating acceptance ещё открыта. [Production direct-peer reader на двух настоящих CHR](mcp-peer-direct-chr-reader-20261028.json): 2500 ms, 12 базовых GET + один измеренный FIB getter, без RIB/мутаций/bandwidth POST; source authority тестовая, mapping/writer fence не доказаны. REST speed original provider прошёл отдельную owned SQL/TCP-приёмку с настоящими Fastify/JWT, endpoint epoch trigger и закрытием pending TCP при disconnect; history неизменна. Scheduler origin прошёл 20 tests. Последний общий peer script: 130 aggregate + 26 mock boundary + 9 network + 7 direct; после обнаруженного на CHR `fib:""` direct suite дополнена до 8 и прошла отдельно. Auth/full MCP, types, lint и build проходят. Осталось 5/10 пунктов текущего этапа: operating stop/restart, production mapping/fences, all-caller activation, public speed workflow и финальная приёмка. Детальные доказательства — в [отчёте](mcp-bandwidth-native-window-acceptance-20261028.json). + +**2026-10-26 — key epoch, operation lock reuse и автоматический scheduler приняты.** [Отчёт](mcp-key-lock-scheduler-acceptance-20261026.json): актуальный существующий ключ проверяется вокруг endpoint SQL и перед commit; вложенные advisory locks используют одно соединение владельца. На owned PostgreSQL прошли 8 реальных lock-сценариев, routing при pool4 с applied/rollback и 2 автоматических scheduler-сценария без наследования человеческого подтверждения. 14 lock unit и 12 scheduler model tests, types/lint/build/full MCP и полный SQL/HTTP с terminal dump/restore/tamper прошли. Internet Path теперь сравнивает три параметра конфигурации: фоновые статусы допустимы, реальное изменение настроек отклоняет старый план. Это закрывает прежние key freshness и pool-capacity замечания в проверенном сценарии; atomic writer barrier, полный readOriginal/independent peer, enrollment/all callers/public speed и остальные пакеты остаются открыты. Реальных CHR, рабочей БД и деплоя не было. + +**2026-10-26 — исходный admission и production origin в routing.measure приняты.** [Отчёт](mcp-routing-bandwidth-origin-acceptance-20261026.json): оба endpoint проверяются до credentials; immutable sealed input, native beforeDispatch и publication привязаны к настоящему шагу. Исходный grant/его отсутствие и общий wall+monotonic admission deadline закреплены при apply; 3 изменения grant и 2 staged pending replay отклонены. Specialized database.restore получает тот же допуск. **29 unit-проверок**, types/lint/build/full MCP и полный SQL/HTTP с настоящим terminal restore/tamper прошли. Batch core identity сохраняет все guards и validators: core24/node25 снова прошли с TTL5s, структурно 17→9 control SQL на node query. **Открыты key-epoch freshness и pool-capacity seam, полный readOriginal/independent peer (включая external), all callers/enrollment/writer barrier/public speed и остальные пакеты.** 358 tools/5 pending/39 migrations, глобальная активация не выполнена. + +**2026-10-26 — текущая SQL-привязка service step принята отдельно.** [Дополнение](mcp-service-current-plan-acceptance-20261026.json): настоящий runner проверен в двух режимах; 12 положительных сверок и восемь отказов при подмене intended state, sealed evidence/payload и operation version. Own-only профиль `test:mcp:service-context:pg`, types/lint/build/unit прошли. **Последний полный broker-прогон не принят: исчерпан observation deadline при source resolution; TTL и binding guards сохранены.** Надёжность общего прогона остаётся открытой наряду с production readOriginal/peer provider и caller migration. + +**2026-10-26 — исходный service-step контекст настоящего MCP runner принят.** [Отчёт](mcp-bandwidth-service-origin-acceptance-20261026.json): human-approved и explicit autonomous (`approved=false`) планы, четыре verified service steps, закрытый точный input и ciphertext-привязки, запрет копий/чужого ALS/следующего шага/позднего продолжения. В owned PostgreSQL прошли 25 node/broker-групп и отдельно 24 core-группы, restore/fault, 2 bandwidth POST и 58 license GET; types/lint/build/full MCP regression. Независимые публичные файлы читаются параллельно; секрет читается после них, TTL и binding checks сохранены. **Это foundation рабочего authority provider: readOriginal, независимый peer proof, caller migration, enrollment/writer barrier и полный speed workflow ещё открыты.** Реестр остаётся 358 tools / 5 pending / 39 migrations. + +**2026-10-26 — сквозная приёмка общего bandwidth execution host прошла.** [Отчёт](mcp-bandwidth-execution-host-acceptance-20261026.json): **25 actual PG-групп**, включая шесть broker-сценариев, 2 physical localhost bandwidth POST и 58 genuine license GET. Original context охватывает все три ID; third credentials не читаются при запрете; exact remote policy независима от credential provider. Изменённый subject нового GET отклонён до reserve/POST. Response>5,3s завершает journal и configuration audit без удержания четырёх core leases; lost response удерживает unknown и блокирует повтор; tombstone после resolve останавливает отправку. Проверены restore восьми control-таблиц, types/lint/build/full speed. Пользователь явно разрешил owned helper, первоначальный отказ снят. Повторный session.binding сохранён: его удаление было отклонено, применена проверенная альтернатива — новые genuine observations только между фазами, без продления original grants/peer expiry. **Далее production authority/peer providers, caller migration, genuine enrollment/writer barrier и полный public speed workflow**. 5 pending/39 migrations и весь исходный план остаются открытыми. + +**2026-10-26 — retained poll capacity видна в MCP → Обзор.** [Приёмка](mcp-poll-retained-capacity-acceptance-20261026.json), [оценка](mcp-poll-retained-capacity.md): личные retained admissions, admin global counts, non-admin только global exhausted; сообщение owner/global full, текущий retention, unavailable вместо нулей/старого зелёного статуса. Один read-only scoped SELECT, connector 3s; GC/лимиты/исполнение не меняются. **13 actual PG-групп + terminal restore семи таблиц/128 admissions**, units8+contracts2, весь UI19 и desktop/mobile3, types/build/lint прошли. UX и количественная оценка приняты; **ёмкость для частого autonomous polling ещё не принята**: при 14 днях owner8 даёт номинально ≤0,57 admission/сутки, часовой режим требует ≥336. Public tools/routes/pending и normal39 не менялись; полный план открыт. + +**2026-10-26 — общий bandwidth execution host собран, его полная приёмка ещё ожидается.** [Срез](mcp-bandwidth-execution-host-progress-20261026.json), [ABI и ограничения](mcp-bandwidth-execution-host.md): genuine observations → resolve-only registry → same-physical alias MAC fence → journal → strict native transport. Source/actual remote/credential provider разделены, исходные права всех трёх обязательны; observation TTL ограничивает predispatch, native response сохраняет original budget. **24 actual core PG / 19 node PG / 28 journal fault-restore**, observation13 и permit18, full speed/types/lint/build прошли. Исправлен durable ACK: SET LOCAL synchronous_commit=on, восемь реальных COMMIT проверены при session default off. Это принятые предпосылки, **не terminal acceptance нового host**: automatic review отклонил добавление его guarded application-pool/localhost helper; файл и запуск отсутствуют, отдельный вопрос человеку задан. Далее приёмка host, production authority/peer providers, caller migration, genuine enrollment/writer barrier и полный public workflow. 5 pending и 39 migrations сохраняются; полный план открыт. + +**2026-10-26 — strict native bandwidth transport принят.** [Приёмка](mcp-bandwidth-native-permit-acceptance-20261026.json): permit выпускается только admission после новых reserve/arm ACK, private WeakMap связывает client/body/path/signal и исходный monotonic deadline. Последний synchronous consume перед actual HTTP, snapshots endpoint/credentials/body, отказ copy/replay/aliases/apiPath обходов. **18 unit/HTTP-групп / 6 localhost POST**, **22 actual PG-группы**, включая 7 PG→strict HTTP сценариев / 3 POST; после потери arm ACK — 0 POST, unknown сохраняет обоих владельцев. Полный speed-workflow/types/lint/build прошли. **Staged strict path ещё не включён для всех callers**: дальше broker, independent remote binding, original REST/scheduler/MCP authority, genuine enrollment/writer barrier и полный public speed workflow. Public pending остаётся 5, normal migrations — 39; общий план открыт. + +**2026-10-26 — устранён дополнительный checkout при подписи bandwidth journal.** [Приёмка](mcp-bandwidth-core-same-lease-acceptance-20261026.json): новый signer использует собственный проверенный core lease, фиксирует payload до await и отвергает fake/foreign/released lease, drift ключа, отмену, expiry и отзыв исходного dispatch grant. **15 actual PG-групп**, в том числе четыре настоящие транзакции одновременно удерживают весь пул и проходят reserve/no-send без пятого checkout. Повторно node source 17, strict types/lint/build прошли. Следующая граница — production broker, original REST/scheduler/MCP authority и mandatory native fence всех callers. Отдельно учтён произвольный diagnostic remote: credential provider не доказывает canonical identity фактического получателя. Все 5 публичных pending пока остаются; общий план открыт. + +**2026-10-26 — операционная сборка canonical node source принята как компонент.** [Отчёт](mcp-bandwidth-node-source-acceptance-20261026.json): отдельный pinned issuer key, подписанный bootstrap, точные catalog/role проверки и один физический core lease на session. **17 actual PG-групп**, повторно core source 13 / registry 18, unit cases 16+16, strict types/lint/build прошли. Четыре параллельные session не требуют пятого checkout; окончательный source/actor guard выполняется после metadata на том же lease. Отзыв исходных прав до INSERT/COMMIT, подмена ключа/bootstrap во время metadata, закрытие зависшего guard, поздний raw fence и public restore восьми control-таблиц проверены. Это заменяет прежний открытый пункт о реализации key/source/catalog/role assembly. **Настоящая human enrollment/quiescence authority, общий writer barrier, Linux issuer/fault, обязательный native guard всех callers и public speed workflow остаются открытыми.** Public pending — 5, normal migrations — 39; весь план ещё не завершён. + +**2026-10-26 — постоянный canonical node registry принят как SQL/host-компонент.** [Отчёт](mcp-bandwidth-node-registry-acceptance-20261026.json): четыре внешние control-таблицы, immutable UUID/claim bindings, отдельные alias incarnation, необратимые terminal aliases и HMAC-цепочки событий. **18 групп actual PG / 37 localhost license GET**, минимальная отдельная LOGIN-роль, два независимых backend/client, два реальных COMMIT ACK loss, rows-only ACK отказ, expiry/откат часов/revocation, replica guards и настоящий public restore. Новый alias с теми же наблюдаемыми claims сохраняет прежний UUID и не обходит реальную unknown-busy пару; все семь control-таблиц пережили public restore без изменения хешей. Независимый review выявил и исправил authority-check gap после async inspect и недостаточность rows-only COMMIT ACK. Types/lint/build прошли. [Оба реальных CHR подтвердили read-only observation](mcp-bandwidth-node-actual-chr-readonly-20261026.json), их текущие typed vectors различаются; 194.226.121.150 доступен через pinned SSH TCP forwarding менеджера, прямой Desktop HTTP сбрасывается. Значения IDs/credentials не раскрыты, CHR/рабочая БД не изменены, реальные node UUID не создавались. **Операционная сборка issuer key/source/catalog/ролей и настоящая enrollment/quiescence authority ещё открыты**, как и Linux issuer/fault, capacity/retention, mandatory guard всех callers и полный public speed workflow. Public pending остаётся 5, normal migrations — 39; весь исходный план не завершён. + +**2026-10-26 — вход для canonical CHR enrollment получил настоящую native-приёмку.** [Отчёт](mcp-bandwidth-node-observation-acceptance-20261026.json): fixed `GET /system/license`, оба typed claim (`system-id`/`software-id`), private same-reader WeakMap provenance, текущие source/credentials до и после IO/при inspect, исходный signal и 5s wall/monotonic deadline. **12 групп / 29 actual localhost GET**, 0 default DB calls, без реальных CHR и без node UUID; full speed-workflow, strict types, lint/build прошли. Это genuine observation, а не физическая уникальность/clone resistance или готовый registry. Также подготовлен source-only Linux bundle с 39 byte-pinned миграциями; read-only стенд подтвердил Linux x86_64/glibc2.41 и directory fsync, но host Node/PG отсутствуют, Docker inventory зависает. Передача bundle и изолированный runtime/PG запуск отклонены automatic review до отдельного явного разрешения на payload/назначение; вопрос пользователю задан, удалённые мутации не выполнялись. Далее Linux issuer/fault приёмка, постоянный registry/aliases/incarnations/tombstones, общий native guard и полный public speed workflow. **Public pending остаётся 5; полный план открыт.** + +**2026-10-26 — подписанный bandwidth source и реальные отдельные SQL-роли приняты как компонент.** [Приёмка](mcp-bandwidth-control-source-acceptance-20261026.json): runtime-reader проверяет Ed25519/pinned SPKI, внешний record/ключ событий, immutable source identity, роли и точный каталог до/после SQL. **13 групп SQL-приёмки с тремя настоящими ограниченными LOGIN-ролями**, actual public dump/restore, restricted complete/no-send и подменами подписи/ключа/ALWAYS guard; 16 role/catalog unit cases и прежняя 28-групповая journal-регрессия прошли. Исправлены ненужная UPDATE-привилегия occupancy и обход проверки application role через PostgreSQL URL query overrides. Явный offline issuer реализован с pending/file+directory fsync, synchronous COMMIT и no-replace публикацией. Текущая Windows FS отказывает directory fsync: проверен отказ issuer до SQL/записи файлов; **положительная приёмка окончательного issuer на поддерживаемой FS остаётся открытой**, reader использует явно synthetic owner-installed identity. [Операторская процедура](mcp-bandwidth-control-source.md). Normal migrations остаются 39; startup не создаёт источник/ключи/поколение. Далее issuer FS/fault-приёмка, canonical CHR enrollment, capacity/retention, mandatory guard всех callers и полный speed workflow; полный rollback/clone fence самого control-провайдера и общие DB-1..4 остаются открытыми. **Public pending по-прежнему 5; весь план не завершён.** + +**2026-10-26 — bandwidth control-журнал защищён от обычного application restore.** [Отчёт](mcp-bandwidth-control-preservation-acceptance-20261026.json): source SQL/DDL теперь используют фиксированный внешний `mmapp_control`, без fallback на public/search_path и без автоматического provisioning или переноса непустого журнала. **28 групп реальной PG-приёмки** включают настоящий `runPrivateDatabaseDump` и `restorePartitionedDatabase`: public marker откатывается, но даже занятость, созданная ПОСЛЕ backup, сохраняется вместе с хешами control-данных и guards. Full archive с control отклоняется до decode/replacement; missing namespace/public shadows не дают ACK. Independent review выявил и исправил evidence TOCTOU: verifier, SQL и event MAC используют один синхронный immutable snapshot. Types/lint/build прошли. Это не production source-generation issuer, canonical enrollment, role isolation или защита от полного rollback самого control source; эти границы остаются открытыми. [Конкретный следующий шаг](mcp-bandwidth-enrollment-next.md): fixed issuer/роли/enrollment, затем обязательный guard всех callers и полный speed workflow. **5 public pending сохраняются; весь план не завершён.** + +**2026-10-26 — SQL-слой общего bandwidth-журнала принят в изолированном профиле.** [Отчёт](mcp-bandwidth-sql-journal-acceptance-20261026.json): атомарная занятость двух canonical keys, immutable binding, новые ACK только после COMMIT, HMAC событий и no automatic unknown release. **21 группа настоящих PostgreSQL-сценариев**, два процесса/несколько соединений, четыре реальные потери COMMIT ACK, завершение backend после synthetic HTTP send, dual-client CAS, expiry/deadline, запрет DML/TRUNCATE и replica-mode guards прошли. Собственный dump/restore сохранил хеши трёх таблиц, 9 ALWAYS guards и 6 fixed-search-path функций. Это opt-in proposal: **normal migrations остаются 39**, production enrollment/restore readiness и штатные native callers не изменены; SQL сам не подтверждает private HMAC/native/no-send evidence. Далее trusted generation/canonical enrollment и capacity/restore boundary, обязательный guard всех dispatch callers, полный MCP speed workflow с историей и финальная приёмка. **Публичных pending остаётся 5**, весь исходный план открыт. + +**2026-10-26 — начат полный MCP speed-test: принята общая admission foundation.** [Отчёт](mcp-speed-admission-foundation-acceptance-20261026.json): отменяемая локальная очередь сохраняет locks до фактического завершения callback; native bandwidth/Ping получили admission/abort hooks. Чистая модель общего допуска связывает исходные поколения/expiry, оба узла и полный native command через keyed proofs; разрешение одноразовое и возникает только после нового arm ACK. После возможного send неизвестный результат не освобождает узлы и не повторяется. **21 adversarial model + 8 queue tests**, настоящая loopback HTTP-проверка, speed REST/persistence, probes/MCP regression, strict types/lint/build прошли. Native response сохраняется до прежних 8 МиБ без нового ограничения на число строк/opaque поля; duration до 2147483647 сохранена. Это ещё не SQL journal, canonical enrollment или межрепличная production-защита: [следующая общая граница и все callers](mcp-speed-common-gate-design-20261026.md). Публичный speed-tool не зарегистрирован; **5 pending остаются**. Далее SQL occupancy и trusted generation/restore, wiring всех native callers, полный speed workflow с историей и сквозная многопроцессная приёмка. + +**2026-10-26 — полный опрос сервера через MCP принят.** [Сквозной отчёт](mcp-server-poll-public-acceptance-20261026.json): `mm_server_poll_plan` → подтверждение человеком → actual operation → apply/verify завершились за **151506 ms** при исходном лимите 180 секунд: 4 GET, 147 действий обслуживания, 151 публикация, 312 строк журнала. Проверены исходный sealed допуск/expiry, актуальные права, безопасный результат, чтение квитанции и повтор apply без новых native/charge. Настоящий dump/restore/tamper и точные хеши семи таблиц прошли. Сокращено число SQL-запросов для одного согласованного context; проверки до/после фактического SQL сохранены. Полный REST executor также принят на новом reader (72994 ms); штатный UI REST handler не переключён. Retained capacity owner8/global128 и понятное сообщение об исчерпании остаются отдельной эксплуатационной задачей. **358 инструментов; 195 mediated / 62 excluded / 5 pending.** Исторические отказы сохранены, но их старый статус poll заменён этим результатом. Актуальные незавершённые требования и критерии: [реестр оставшейся работы](mcp-remaining-work-20261026.md); полный план ещё не завершён. + +**2026-10-26 — разрешение на экспорт существующих IPsec-ключей исполнено и повторно проверено.** [Отчёт](mcp-existing-ipsec-export-reverification-20261026.json): активны client/server/other/CA, оба REST-пути требуют вошедшего пользователя с network-write и явного `privateKeyExport=true`, CA дополнительно требует `caPrivateKeyExport=true`. MCP требует человеческого подтверждения, возвращает модели только квитанцию, а владельцу предоставляет зашифрованный файл на 15 минут. Повторно прошли профиль unit-тестов, пять сквозных SQL/HTTP-сценариев на 39 миграциях, настоящий dump/restore и отказ для подменённого архива. Только синтетические ключи; деплоя и экспорта ключей CHR не было. Исторические записи ниже про отсутствие разрешения/flag=false относятся к состоянию до активации и этим результатом заменены. Полный MCP-план остаётся открытым. + +[Штатный poll-storage enrollment и worker lifecycle закрыты](mcp-server-poll-storage-enrollment-acceptance-v1.json).0039 содержит25statements/6reviewed bodies, atomicmarker, fresh-only guards с сохранностью непустого0038журнала; normal manifest39/selfmanaged22/owner17, sentinelLIMIT40, requiredtables+2,18pristine seedtables. Legacy probe prefix0036 сохраняется, фиксированныйownedpoll prefix0038 используется только в тестах. Fresh-profile выполняет реальный applySqlMigrations дважды; lifecycle-profile исполняет весь literal0039 на одномbackend/transaction с явно синтетическими aged-records междуDDL иguard creation, включая настоящий terminalmarker, безподдельногоmarker/time travel/triggerdisable. **Оба current39 профиля** проходятfullquota poll4GET/147maintenance/151publications/312rows, global128cap, realterminaldump/restore иhash/count сравнение7таблиц(tokens/ciphertext/counters). Существующий tick послеprobeRetention запускаетpollGC приMCPdisabled, stop/drain/sharedgate сохранены, новыйtimer отсутствует; actualexpiredadmissionудалён сrefund черезrealworker.9новыхlifecycleunit,45storageunit+полнаяmanagement/writerregression,17manifestcases, strict/lint/build, independent39reference/18seeds ипубличныеdiagnostic6/IPsec4roleSQL/restore прошли. Найдена и закрыта реальная DB-race: predicate itself locks authoritative retention `FOR SHARE`; конкурентный UPDATE блокируется. Следомread-only authenticated receiptreader(неclaim/execute), sealedoriginalprincipal/owner/receipt/expiry иrealopID/humanapproval, wholeactualauthority/RESTperformance под180s, затемpublicpoll. Практическая storagequota ёмкость/сообщения передpublicтакженуждаютсявпроверке:128global/8owner—retainedadmissionsдоGC, неconcurrentоперации. Прежниеcoverage357/262routes/194mediated/62excluded/6pending, ориентировочно5крупных направлений до полного плана, бездеплоя/реальныхCHR. + +[Private poll storage: admission/quota/GC и сквозная сборка проверены](mcp-server-poll-storage-acceptance-v1.json). Созданы pure accounting model, bounded physical owner, journal bridge иfixed retention entry. Две подтверждённые транзакции reserve→consume+anchor; original identity/owner/connection/proofs/expiry и stage budget immutable, повтор admission без anchor означает spent.8MiB+32KiB/4019journal rows на попытку; global128/owner8, counters атомарны. DB guards метят только фактические INSERT, включая AFTER-meter для idempotent quarantine; нормализуют activity time, UPDATE запрещён. Перед любым identity/row lock берётся global storage key. Independent quarantine получает10s после уничтожения старого backend и не требует живого исходного допуска; новая reservation при cleanup не создаётся. GC20s/batch8 входит shared database gate, блокирует текущий retention setting, проверяет original expiry/последнюю активность/max persisted-current retention/safety и actual terminal original poll op/connection/owner; удаляет все children+admission иrefund в одной транзакции.36новыхunit; actualSQL10случаев, настоящие ACK-loss после reserve/anchor/intent/GC COMMIT, concurrent quota/rollback/expired-source guards,4nativeGET+147maintenance+151publication+312rows иreplay без native/charge прошли. После настоящего terminal restore сверены7полныхтаблиц hash/rowcount, включая tokens/ciphertext, global/owner counters ипрежний128cap. Исторические GC-данные синтетические ивставлены ДО guards; нет time travel/trigger disable/forged marker. **Normal schema ещё38, новая схема proposalONLY:** следующим этапом штатный fresh-only enrollment, fixed worker lifecycle и real sealed human-approved MCP/runtime/current authority host. Original prepared expiry нельзя переиздать при restart/GC; вpublic adapter это ещё предстоит закрепить. Новая private REST factory выбрала quota-bound session; publicroute/coverage ещёне менялись. Coverage357/262routes/194mediated/62excluded/6pending; ориентировочно5крупных направлений остаются. + +[Этап полного server poll: enrollment и durable execution завершены](mcp-server-poll-enrollment-acceptance-v1.json). Нормальная0038 атомарно устанавливает5таблиц append-only журнала и ALWAYS guards12partition roots, берёт exclusive advisory key до инспекции/DDL и отказывает на unmarked схеме. Manifest, history overflow sentinel, independent reference и pristine data verification обновлены до38; legacy prefix0036 не меняется. ActualSQL/HTTP:4nativeGET +148maintenance +152публикации, same-backend exclusion, blocked ordinary writer BEFORE relation SQL, replay без native, revocation и dump/restore/tamper прошли. Устранены повторные чтения purpose key при аутентификации растущего journal prefix; решения о правах не кэшируются. Одной оптимизации было недостаточно: старый120s общий envelope завершался UNKNOWN после118/144ACK; установлен общий180s предел для журнала/DDL с прежними native15s/SQL5s/original expiry, оба контрпримера сохранены. Новые текущие authority и private REST host (точный top-level SnapshotRead, safe404, UUID/JWT identity, whole-host cancellation) прошли18unit, strict/lint/build. **Это не public poll acceptance:** до регистрации обязательны journal admission/quota/GC, реальная MCP-operation identity и human-approved sealed plan, SQL/HTTP всего нового REST/MCP host с настоящими authority factories; отдельно оценить65тыс. fresh authority boundaries на холодном148-effect poll. Синтетическая IPsec/CA SQL/HTTP-регрессия всех4ролей и public diagnostics6tools также прошла на38миграциях. Coverage357/262routes/194mediated/62excluded/6pending; ориентировочно5крупных направлений остаются, стенд/CHR не менялись. + +[Публичная диагностика REST/MCP и retention подключены](mcp-diagnostic-public-v2-acceptance-v1.json): приложение использует общий v2 REST handler, реальный реестр даёт `mm_server_probe_plan`, `mm_server_probe_receipt_get`, `mm_server_probe_cancel`; stop выполняется сразу с актуальным write/owner/run-допуском. Существующий worker обслуживает expiry даже при отключённом MCP, ограничен20s/16owners/25records и возвращает metadata/payload квоту в транзакции; shared gate ждёт завершения, новый timer не добавлен. Настоящие startup37 → public registry → human approval → все6инструментов → pages/checksum/read-only rotation, отмена ДО первого send и ВО ВРЕМЯ send прошли SQL/HTTP/DNS приёмку. DNS реально прошёл owned UDP→TCP; подменён только endpoint конструктора Resolver в тестовом процессе, не методы/результаты. REST проверен через **actual buildApp**, не отдельный plugin fixture. Найден и исправлен дефект: route получен и stage ACK записан, но10s общего бюджета не хватало на текущие fences/receipt. Добавлен фиксированный30s control reserve с прежними wire timeout/counts/private expiry; guard/metadata timeout и no replay сохранены. Убраны лишние mkdir/open-EEXIST при каждом чтении существующего state, но key/generation читаются заново без cache. **85 execution cases,5 retention,3 security, registration/management/build/strict/lint,22storage/GC и133legacy SQL +полная routing-authority регрессия, dump/restore/tamper прошли**. Coverage: **357 tools /262 routes /194 mediated /62 excluded /6 pending**, unmapped0. Старый synthetic six-tool helper заменён только после более сильной fresh public-registry приёмки всех8unique lanes. Деплоя/реальных CHR не было. Следом6manual poll/collect/speed routes и остальные5крупных направлений; общий multi-replica DB control/legacy backfill и реальная forwarding/controller/3node/UI-приёмка ещё открыты. + +[Fresh-only v2 installation принята](mcp-diagnostic-fresh-v2-installation-acceptance-v1.json): новая **0037** вошла в обычный startup migration discovery. Она блокирует4legacy-таблицы, отказывает до DDL при любой истории/незарегистрированной v2 relation и создаёт только пустое v2-хранилище с global usage0; backfill/copy отсутствуют. Manifest/reference/verifier обновлены до **37 файлов**, overflow sentinel38, mandatory3таблиц и17точных seeds. Реальный normal startup выполнен дважды; непустой quarantine остался неизменным вместе с legacy ciphertext column и без фиктивного marker. На этой штатно созданной v2-схеме прошли **MCP runtime (2 HTTP127 native), REST/JWT/local (8 HTTP127 native), cancel/history/quota и полный dump-restore/tamper**. Независимый controlled reference/actual data verifier прошёл8SQLслучаев; manifest15, dispatcher10, verifier7 unit/build/strict/lint прошли. Старые22storage/GC и8orphan-сценариев работают через явный pinned0036 fixture; их положительный current-version restore перенесён на fresh-v2 профиль, marker не подделывается. На удалённый стенд ничего не применялось. Далее — production REST/MCP wiring и retention lifecycle; непустой dev legacy/backfill, общий DB control и прочие5направлений полного плана остаются открыты. Pending public routes7. + +[Первая установка и промежуточный legacy теперь разделены по фактическому состоянию](mcp-first-install-readiness-20261025.json): Git HEAD `5efd0339` содержит SQL только до0014, все MCP-миграции ещё в worktree. На предоставленном стенде **read-only** проверены Docker images и schema_migrations: backend `ux-certificates258`, последний marker0012, **таблиц MCP/server_probe нет**. Поэтому первая установка должна получать v2 через отдельную fresh-only0037 с транзакционными locks и отказом при любой существующей anchor/stage/quarantine/cancellation; исторический backfill остаётся отдельной задачей dev-upgrade/restore, а не предпосылкой первоначального создания пустого MCP-журнала. Это не утверждает, что любая другая установка пуста. Миграция/деплой на стенде не выполнялись. Далее: сама0037, manifest/control/reference inventory, явные legacy fixture schemas и fresh-v2 positive restore; затем public REST/MCP wiring и retention. Непустой legacy нельзя молча переписывать или снабжать фиктивным marker. + +[Фактическая legacy MCP orphan-приёмка завершена](mcp-diagnostic-legacy-orphans-acceptance-v1.json): отдельный профиль остаётся на0036 без v2. Реальная криптография, original sealed plans, exact intent step и fixed source-paired host приняли completed receipt, orphan quarantine0/0 и orphan cancellation, отклонили ещё5 вариантов без достаточных доказательств. Рабочий sibling credential при enabled MCP подтверждён отдельно от отказа истёкшего original. Итог **8 identities:3 verified/5 unresolved**, таблицы/схема не изменились при чтении, новые anchors/payload/native не создавались. SQL/dump-restore/tamper, strict types, lint/runner syntax прошли. До полного плана ориентировочно5 направлений; ближайшее — fresh-v2 first-install migration и подключение оставшихся7маршрутов, при сохранении отдельного legacy-backfill пути. + +[Текущий источник и данные legacy связаны одним owned snapshot](mcp-diagnostic-source-pairing-acceptance-v1.json): фиксированный pg_catalog reader повторно читает system identifier/DB OID/roles/version/recovery и закрепляет backend PID, полный timestamp транзакции и snapshot. Private observation привязан WeakMap к исходному facade, deadline/signal; JSON/копия/другой client не дают подтверждения. Новый внутренний `inspectLegacyServerProbeSource` читает source до реального collector и перепроверяет после в том же lease, затем проходит свежую проверку прав. **20 новых unit**, actualPG source/data pairing, две временные БД одного кластера с разными OID и переставленный search_path с отвергающим shadow type/operator проверены; SQL/HTTP/runtime/dump-restore/tamper, management/build/strict/lint прошли. Это факты текущего источника, **не независимое подтверждение поколения/архива**; generationAuthenticated/READY/conversionAllowed остаются false. Следом external source-generation binding, backfill и writer/DDL drain. Публичных pending остаётся7; ориентировочно5 крупных направлений до полного плана. + +[Защищённый операторский запуск проверки legacy принят](mcp-diagnostic-legacy-inspection-acceptance-v1.json): внутренний fixed-production host требует актуальный JWT приложения `mm` и `mm:settings:admin`, сохраняет исходный actor и проверяет portal revocation на независимых свежих lease до чтения и после освобождения snapshot. Весь callback, включая crypto/FS gaps, ограничен signal/deadline/JWT/300s, SQL ≤10s; поздние continuation/checkout не продолжают SQL. Реальный PostgreSQL подтвердил чтение двух старых квитанций, отказ при отзыве до чтения и между сбором/выдачей, отмену **до снятия relation lock за <2s**, исчезновение backend после unlock и неизменность anchors. **9 новых unit**, strict types включая tests/SQL helper, lint, backend/contracts build, полный management и SQL/MCP/REST/JWT/HTTP/dump-restore/tamper прошли. Public coverage остаётся **354/262/193 mediated/62 excluded/7 pending**, unmapped0. Публичного API и conversion grant нет: далее source-generation authority, authenticated backfill, writer/DDL drain и locked conversion. Ориентировочно остаются **5 крупных направлений**: переход схемы и7маршрутов; контроль БД/восстановление; native-маршрутизация/трафик; UI/AI-клиенты; итоговая регрессия/стендовая приёмка. + +[Внутренняя проверка старых квитанций и read-only snapshot приняты](mcp-diagnostic-legacy-snapshot-acceptance-v1.json): общие protocol parsers сохраняют поведение journal; новый verifier проверяет encrypted receipt/MAC точной plaintext-строки, original MCP sealed source, anchor/stages/quarantine/cancellation. Старый identity MAC подтверждает только context: binding/progress/timestamps не объявляются доказанными; orphan progress без ledger остаётся unresolved. Фиксированный SQL collector собирает union идентичностей и отдельные orphan stages, ограничивает batches/total/deadline и возвращает только counts/reasons/aggregate checksum. Требуется явный READ ONLY REPEATABLE READ: autocommit с такими session defaults отклоняется. **69 verifier +20 snapshot checks**, реальный PostgreSQL/production crypto/legacy writer подтвердили: параллельный COMMIT не меняет текущий snapshot, следующий видит обе квитанции, повторный digest стабилен; read-only original MCP source совпадает с locked digest и сохраняет истёкший original context. Полные management/SQL/runtime/HTTP/dump-restore/tamper/build/types/lint прошли. **migrationReady/conversionAllowed всегда false**: source DB attestation, operator host, authenticated backfill, writer/DDL drain и locked conversion ещё впереди. Public coverage **354 tools /262 routes /193 mediated /62 excluded /7 pending**, unmapped0. До полного завершения ориентировочно **5 крупных направлений**: переход схемы и7маршрутов; контроль БД/восстановление; native-маршрутизация/трафик; UI/внешние AI-клиенты; итоговая регрессия и стендовая приёмка. + +[Отмена managed metadata/owner SQL принята](mcp-diagnostic-metadata-cancellation-acceptance-v1.json): воспроизведён отказ прежнего пути завершить вызов при удерживаемом PostgreSQL relation lock. Теперь отдельный request window передаёт signal/deadline в owned metadata/owner/portal lease ≤10s; ограничен и callback между SQL-запросами. Строго проверяется закрытие родительских контекстов, включая сохранённый raw client; borrowed planner получает guarded facade и сохраняет своего владельца. Настоящий inherited verified guard не заменён; quarantine имеет отдельные 10s и не зависит от отменённого окна. Реальные factory и **HTTP/JWT handler завершились за 1 ms при удерживаемом lock**, без native/admission; backend исчез после unlock. **6 новых unit**, MCP/REST runtime, 22 journal/6 driver SQL-сценариев, management/build/types/lint, dump/restore/tamper прошли. Устранена нестабильность owned restore fixture: фоновые collectors архивируются выключенными для каждого тестового профиля, production scheduler не менялся. Caller-owned planner, inherited runtime/watchers/cancel host ещё сохраняют собственные timeout/lifecycle правила; не объявляется их полная унификация. Public enrollment/backfill/drain/retention worker и полный план остаются открыты; **354/193 mediated/62 excluded/7 pending**, unmapped0. + +[REST-сборка и HTTP-адаптер v2 приняты в исходниках](mcp-diagnostic-rest-storage-acceptance-v1.json): общий journal I/O выделен для фиксированных MCP/REST transport. Новый REST factory использует реальный portal/JWT/local authority, выбирает только source+фактический BW peer и работает при MCP disabled и **1001 постороннем сервере**. 404 сохраняется после проверки входа, body не задаёт owner/transport/auth mode, квитанции и квоты общие с MCP. Неподключённый к startup HTTP plugin берёт actor/config с сервера, генерирует UUID и отменяет работу при disconnect. Реальные Fastify/JWT/HTTP127 проверки дали **8 REST native-запросов**, подтвердили 401/403, неизменный `{output}`, отказ подмены body/header, BW fallback/disabled, безопасные ошибки, shared quota и quarantine после отмены. Прежний сквозной MCP runtime (2 native, approval/cancel/history), 22 journal/6 driver SQL-сценариев, dump/restore/tamper, 4 новых unit/11 REST execution cases и management/build/types/lint прошли. Старый production route не переключён. До public readiness остаются authenticated legacy enrollment/backfill/drain, retention worker и короткие сроки/отмена внешних metadata/authority SQL; 10s bound сейчас доказан для owned journal SQL/cleanup. Полный план открыт; **354/193 mediated/62 excluded/7 pending** без изменений. + +[Общий v2 run/read/cancel host принят в исходниках](mcp-diagnostic-storage-host-acceptance-v1.json): opt-in factory собирает реальные authority/executor/journal/history/cancel компоненты. Journal methods используют свой bounded backend; source/owner/protection SQL не делают обходного checkout под storage lock. Права подтверждённой операции проверяются штатным inherited verified guard вне journal lease. Planner transactionClient используется без захвата чужого lifecycle, stale ALS children отказывают. Сквозной **настоящий MCP runtime** на временной PostgreSQL/HTTP127 проверил план → запрет без человека → approval/apply → один native → metadata-only receipt → историю новым read-only токеном и отказ старого/записывающего доступа. Второй native остановлен **двумя конкурентными cancel**: watcher закрыл socket, один audit, потраченная admission, quarantine без payload, состояние unknown и запрет history/replay. Исправлен выявленный review лимит: journal учитывает private prepared expiry и допускает весь MTU budget, не продлевая план; SQL ≤10s, quarantine отдельно 10s. **4 host unit, 152 adapter assertions, 22 journal/6 driver/12 history SQL-сценариев**, dump/restore/tamper, management/build/types/lint прошли. Это принятая source assembly; **production enrollment/legacy drain/backfill, retention worker и public registration ещё открыты**, инвентарь **354/193 mediated/62 excluded/7 pending** не менялся. Полный план не завершён. + +[SQL-источники и bounded connector диагностики приняты](mcp-diagnostic-storage-connection-source-acceptance-v1.json): production executor получил trusted hooks для чтения источников без обходного checkout; неполные и runtime-null hooks отказывают без fallback. Scope применяется до чтения credentials, BW selection читает только metadata. Реальные prepare/preflight с production crypto прошли **18 SQL-запросов на одном backend при запрете pool.query**; смена endpoint proof и disabled peer отклонены. Новый connector ограничивает checkout/query deadline, signal, wall timer и query_timeout; lost ACK/abort/overlap уничтожают lease без повтора. **4 source +20 connector unit tests**, постоянный management profile, **6 настоящих driver SQL-сценариев с pool.max=1 +22 journal SQL-сценария**, dump/restore/tamper, build/types/lint прошли. Driver и source-компоненты приняты; полная runtime authority, общий v2 run/read/cancel host, production schema enrollment и public registration ещё не завершены. Инвентарь **354/193 mediated/62 excluded/7 pending** прежний. + +[Карантин неопределённой диагностики исправлен](mcp-diagnostic-quarantine-cleanup-acceptance-v1.json): после фактического terminal COMMIT и потери ACK отмена запроса, истечение deadline или смена policy больше не блокируют запись карантина. Только эта запись получает отдельный 10-секундный бюджет; требуются точная существующая оплаченная admission и прежние identity proofs/locks. Обычные операции сохраняют текущие проверки прав, policy, deadline и signal. **11 model-тестов, 22 SQL-сценария с 8 фактическими потерями ACK**, dump/restore/tamper, management profile, build/types/lint прошли. Новое чтение возвращает `unknown`; terminal/payload/stages не переписываются. Это исправление opt-in v2 boundary; production enrollment, общий run/read/cancel host и семь pending routes ещё открыты. Инвентарь **354/193 mediated/62 excluded/7 pending** не менялся. + +[Разрешённая активация IPsec export завершена](mcp-existing-ipsec-export-active-acceptance-v1.json): пользователь отдельно разрешил существующие клиентские, серверные, сторонние и CA-ключи для обоих способов выдачи. Новый `mm_ipsec_existing_certificate_export_plan` зарегистрирован, оба REST пути переведены на общий guarded executor, legacy export/normalization fallback удалён. Общие typed contracts требуют private intent и stable UUID; для CA — отдельный explicit flag. Реальные default production assemblies проверены только на временной PostgreSQL и synthetic RouterOS; MCP human approval, model receipt-only, encrypted owner portal/TTL, current permissions/no-replay/late source guards сохранены. Browser 7/API 5, полный MCP/IPsec/pending foundations, SQL/HTTP и dump/restore/tamper, backend build/frontend/test types/lint прошли. **354 tools;193 mediated/62 excluded/7 pending**. Native CHR key export не выполнялся и разрешением этого этапа не покрыт. Полный план не завершён. + +[Разрешённый cancellation host принят](mcp-diagnostic-cancellation-host-acceptance-v1.json): patch SHA проверен, source применён без изменений поведения относительно одобренного предложения. **17 SQL-сценариев** проверили active/pending/terminal, текущие owner/scope/credentials/settings и drift перед lock, общую квоту, идемпотентный audit, rollback/lost ACK и реальные COMMIT/claim race barriers. Все проверки и профиль server-probe-management прошли, как и dump/restore/tamper. Это opt-in source host; public API, deployment и текущая рабочая БД не менялись. Прежние ожидания разрешения ниже исторические: разрешение получено и выполнено. + +[Сквозной IPsec export сценарий](mcp-existing-ipsec-export-integration-acceptance-v1.json): UI confirmation + обязательный stable request key, HTTP mapping обоих путей, guarded MCP registrar и injectable artifact store подключены. **5 API unit +7 реальных browser cases**, 41 HTTP model case и настоящая SQL/HTTP-приёмка охватывают REST client/by-name и MCP client/by-name для client/server/other/CA. Каждая MCP-операция требует человека; модель получает квитанцию, владелец — зашифрованный artifact через портал, проверены TTL/политика/no-replay/отзыв/отмена. Полный MCP, build/type/lint и dump/restore/tamper прошли. Existing/CA flag=false: штатные REST handlers ещё используют прежнюю реализацию, подготовленный новый registrar не увеличивает каталог; окончательная активация и native приёмка остаются открытыми, реестр 353 /191/62/9. + +Разрешение на `tmp/mcp-diagnostic-cancel-host-reviewed.patch` получено; host применён без API/деплоя/рабочей БД. Его актуальная SQL-приёмка выполняется отдельно; прежние записи об ожидающем разрешении ниже относятся к историческим этапам. + +[Общий IPsec export core, REST authority и журнал](mcp-existing-ipsec-export-transport-acceptance-v1.json): общий native алгоритм сохраняет MCP authority/receipt и отдельно возвращает REST bundle через transport publication. REST без фиктивного MCP principal требует актуального issuedAt/expiresAt/network-write, explicit private/CA intent и стабильного UUID request key; opaque token расходуется до первого await. Keyed proof связывает inline passphrase, который не входит в token/audit. Durable журнал actor/key блокирует повтор после COMMIT uncertainty; checkpoints 1→2 требуют исходный claim, точный receipt-owned файл и финальный исходный proof. **48 модельных +14 SQL-сценариев**, реальный dump/restore/tamper, полный MCP и build/type/lint прошли. Закрытые production assemblies и portal kind готовы, но REST handlers/UI contract/idempotency header, MCP registration/coverage и полная SQL/HTTP transport-композиция ещё не включены. Existing/CA payload approval пока нет, flag=false; текущие REST handlers сохранены до согласованного переключения. Публичное покрытие остаётся 353 /191/62/9, полный план открыт. + +[Закрытый service adapter существующего IPsec export](mcp-existing-ipsec-export-adapter-acceptance-v1.json): подготовлены `prepare/execute/verify/reconcile` для selector client/certificate с обязательными injected dependencies. inputProof вычисляется из фактического service input после шифрования binding, без циклической подписи. Синтетические проверки покрывают роли сертификатов, CA flag, секреты, доступ, drift, deadline, отмену, точный receipt/artifact и VERIFIED-only сверку. Исправлена очистка приватных Buffer при ошибках reader/CREATE/executor и позднем download после отмены. Регистрация MCP, общий REST executor, portal branch, отдельный допуск существующих/CA-ключей и SQL-приёмка нового адаптера ещё нужны; coverage остаётся 353 /191/62/9. + +Уточнение следующего DB baseline этапа: текущий `accessGeneration` относится к отзыву MCP-доступа, а control `epoch` — к смене владельца; ни один не является original database generation. В production пока нет независимого source-generation authority. HMAC от произвольно выбранного caller snapshot не закрывает этот пробел. Для trusted baseline нужны аутентифицированная identity исходной БД/операции и связь с исходным snapshot/archive, отдельно от исторической корректности миграций; READY остаётся закрытым. + +[Приёмка данных через настоящий MCP restore](mcp-terminal-restore-data-equivalence-acceptance-v1.json): равенство всего содержимого архива проверено на границе после миграций, до возобновления consumers (97 таблиц, 8 sequences). Отдельная проверка после завершения связывает архив, владельца, operation и точный audit receipt. Тестовая instrumentation не добавляет production hooks и не игнорирует таблицы планировщика. Исправлены пустая maintenance-запись свежего Flow engine и попытка запуска выключенного коллектора после операции БД. SQL/model/профильные проверки и build прошли. Аутентифицированный production data issuer, историческая эквивалентность миграций и quiescence остаются открытыми; READY/enrollment не выдаются. Полный план и прежние 9 pending не закрыты. + +[Сверка полного содержимого БД](mcp-migration-data-inventory-acceptance-v1.json): реализован read-only PG18 capture/compare для физического содержимого таблиц/partition leaves/materialized views и состояния sequence. Потоковые курсоры, C-order и length-framed SHA-256 сохраняют кратность строк; формат v2 объединяет JSONB с native record, чтобы не терять границы массивов. Сырые значения не входят в результат. **15 модельных и 13 настоящих SQL-сценариев** прошли, включая сравнение до/после настоящего `pg_dump/pg_restore`, сохранение данных при различиях fast-default cache, concurrent snapshot/sequence drift, отказ при overflow и исключение внешней `mmapp_control`. Полный system-database профиль, build, lint и отдельный terminal restore/tamper прошли. Это unsigned evidence-компонент: утверждения о доверенном issuer, исходной generation/archive, historical migration semantics и quiescence всех writers/DDL ещё нужны; READY/enrollment не выдаются. Host отмены по-прежнему ожидает ранее запрошенного разрешения; реестр 353/191/62/9 неизменен. + +[SQL-предпосылки cancellation host](mcp-cancellation-source-prerequisites-acceptance-v1.json): **8 новых SQL-проверок** подтвердили чтение действительного зашифрованного источника pending/running, отказ при подмене шага/version/digest, независимость текущего credential/grant от исходного и отзыв текущего credential. Исправлено распознавание `rejected`/`partial` как терминальных состояний, которые фактически создаёт runner. Сохранены 17 storage/GC-сценариев и настоящий dump/restore/tamper; build, source models и scoped lint прошли. Полный host подготовлен в `tmp/mcp-diagnostic-cancel-host-reviewed.patch`: статическая проверка TypeScript и отдельное review успешны, но файл **не применён и не выполнялся**. Автопроверка требует отдельного разрешения на этот production-pool cancellation/audit wiring; вопрос отправлен пользователю. Эти 8 проверок не доказывают runtime-композицию host. Реестр и 9 pending неизменны. + +[Разрешённый GC диагностики и общая квота отмены](mcp-diagnostic-expiry-gc-acceptance-v1.json): пользователь подтвердил удаление истёкших payloads, anchors, stages, quarantines, admissions и отмен завершённых операций. Патч применён с адаптацией к общей reservation отмены/выполнения: отдельного возврата 2 КиБ больше нет, полная квота возвращается один раз при удалении admission. **17 SQL-сценариев** проверили GC всех шести видов записей, текущий retention и защитный интервал, свежий quarantine, работающую операцию, rollback после DELETE, неизвестный COMMIT, отсутствие replay и сохранность планов/операций/общего аудита. Отдельно повторены **12 SQL-сценариев истории** и настоящий dump/restore/tamper. Source verifier отмены получил 27 tamper-проверок и блокировку исходной операции; повторная отмена добавляет одну запись и одно audit-событие. Полные MCP/diagnostics profiles, build и scoped lint прошли. Это ещё не production-включение: схема остаётся изолированным proposal, host wiring и регистрация run/read/cancel открыты. Полный план и 9 pending сохраняются; рабочая БД и CHR не менялись. + +[История диагностики с отдельным payload](mcp-probe-separated-history-acceptance-v1.json) проверена в **12 настоящих SQL-сценариях**: исходная MCP-квитанция с истёкшим credential читается с новым актуальным read-допуском, без замены исходного envelope. Fixed opt-in reader проверяет admission по receipt или исходной execution identity, раздельные anchor/payload proofs, текущие права обоих участников и карантин; отдельный повторный SQL-снимок после расшифровки обнаруживает конкурентный purge, quarantine и отзыв текущего credential. Истёкший payload и отсутствующий результат различаются, попытка остаётся использованной, метаданные сохраняются. **42 исторических теста, 4 проверки deadline/abort**, полный MCP/diagnostics profiles, build, lint и настоящий dump/restore/tamper прошли. Это закрывает reader для схемы v2; её production-подключение, quota cancellation, полный retention/metadata GC и public registration ещё открыты. Инвентарь остаётся 353 / 191 mediated / 62 excluded / 9 pending; полного завершения плана нет. + +[Этап общего сетевого контекста, эталона схемы и хранилища диагностики](mcp-shared-context-reference-storage-acceptance-v1.json) принят на изолированных стендах. Независимый producer выполняет 36 закреплённых миграций в отдельной временной БД и публикует подписанный эталон только после её удаления; 8 SQL-сценариев проверяют подмену, drift, данные, sequence и потерю ACK создания. Проверка текущего состояния сопоставляет схему, 16 seed-инвариантов, пустые таблицы и sequences, но ещё не выдаёт READY и не доказывает историческую эквивалентность данных. + +Общий приватный сетевой контекст разделяет транспорт, абсолютный deadline и счётчики между configuration bridge и native snapshots. Прошли 15 HTTP-тестов, 14 SQL/HTTP-сценариев (126 запросов, 0 native writes) и полный scoped prepare/approval/apply с настоящим CONFIG_APPLIED. Полный подписанный сетевой результат и автоматический inverse остаются открытыми. + +Опциональный адаптер журнала диагностики проверен в 7 SQL-сценариях: перенос ciphertext из legacy anchor, квоты, отдельный зашифрованный payload, три потери COMMIT ACK и удаление только просроченного payload с сохранением метаданных. DDL остаётся proposal; production-регистрации и metadata GC нет. Полные профили MCP, system-database, диагностики и native readers, backend build и scoped lint прошли. Инвентарь неизменен: **353 инструмента; 191 mediated / 62 excluded / 9 pending из 262 REST-маршрутов**. Полный план не завершён; деплоя и изменений настоящих CHR не было. + +[Новый этап controlled migrations и genuine configuration bridge](mcp-controlled-dispatch-configuration-bridge-acceptance-v1.json): **8 настоящих SQL-сценариев в 7 отдельных БД**, два полных исполнения 36 закреплённых файлов и настоящий dump/restore/tamper прошли. Fixed dispatcher сохраняет intent до SQL, использует exclusive session lease на том же application backend, не переписывает self-managed файлы, проверяет COMMIT command tag и markers, затем сохраняет purpose-separated HMAC квитанцию. Потерянный ACK application COMMIT запрещает повтор; если потерян только ACK публикации уже подтверждённой HMAC-квитанции, новая invocation проверяет prefix и продолжает со следующего файла. Подмена hash одновременно в singleton/history не проходит. Это опциональный внутренний dispatcher: default startup не изменён, READY и schema/data verification не выдаются; актуальная полная сверка схемы/данных и quiescence всех writers перед activation остаются открыты. + +Genuine configuration bridge проверен в полном scoped SQL/HTTP-профиле через prepare/approval/apply: revision-3 persistent manual request и реальный CONFIG_APPLIED, текущий raw owned rule/dependencies, одноразовый WeakMap context и повторный read-back на consume. JSON-копия, close, отключение профиля и подмена checkpoint блокируются без новой native-записи; autoRecovery guard сохранён. **30 bridge и 10 dispatcher модельных тестов**, native профиль 210 тестов, system-database профиль, backend build и scoped ESLint прошли. Bridge пока использует отдельный ограниченный context reader: общий network budget и per-round full proof этим не доказаны, assembly и automatic inverse не подключены. + +[Дополнительное read-only наблюдение CHR](mcp-chr-ssh-forwarding-policy-observation-v1.json) подтвердило forwarding-enabled=no и www port 80 на первом CHR. Второй CHR отклонил новый SSH-вход до чтения настроек; повторов и изменений конфигурации не было. Полная recursive/forwarding-приёмка остаётся открытой. Деплоя, production DDL и новых публичных инструментов нет; полный план и 9 pending не закрыты. + +[Новый этап durable schema control, archive boundary и native assembly](mcp-control-archive-native-assembly-acceptance-v1.json): на временной PostgreSQL прошли **14 сценариев в 9 отдельных БД**, включая конкурирующих владельцев, три потери ACK реального COMMIT, restart quarantine, подмену истории, сохранение control вне public, настоящий pg_dump/restore и отказ архиву с control objects. REST и MCP export теперь исключают mmapp_control; общий restore проверяет TOC до SQL decode и target replacement. Это защита пространства управления, а не проверка произвольного SQL внутри архива. Production control tables/startup/dispatcher ещё не подключены; READY не выдаётся из caller JSON или равенства схемы. + +Native assembly получила **23 теста** и проверена на фактических baseline/двух forward/двух inverse снимках SQL/HTTP-reader: exact scope/source/phase/time и общий бюджет, strict parser для каждого этапа, явный UNKNOWN при отсутствии genuine configuration proof и log interval. Профиль native models теперь содержит **180 тестов**; ещё 20 control, 3 archive boundary и 4 acquisition CLI теста прошли. Общие MCP/system-database профили, backend build и scoped lint прошли; все backend-профили не повторялись. + +[Read-only попытка сбора native recursive evidence с CHR](mcp-chr-recursive-acquisition-attempt-v1.json): SSH к стенду и обоим CHR подтверждён по прежним pinned host keys; оба первых REST GET через временные двойные SSH-туннели завершились без response-body, без повторов и изменений конфигурации. Причина не установлена, raw route/nexthop samples не получены. [Ограниченный протокол сбора](mcp-recursive-native-acquisition-contract.md) и CLI готовы; undocumented numeric joins остаются unknown. Это не приёмка recursive convergence или трёхузлового forwarding. Деплоя и production DDL не было; полный план и 9 pending остаются открытыми. + +[Новый этап native readers и проверки схемы](mcp-native-readers-schema-inventory-acceptance-v1.json): **154 модельных теста**, **14 SQL/HTTP-сценариев (99 запросов, 0 native writes)** и **17 SQL-сценариев schema inventory** прошли на изолированном стенде. Обе SQL-приёмки включают настоящий dump/restore и отказ для подменённого архива. Настоящий loopback HTTPS проверил DNS pin, сохранение Host/SNI, доверенную CA, отказ при неверном имени и недоверенной CA. Полный MCP-профиль, существующие server connection tests, backend build и scoped ESLint прошли. + +Reader закрепляет текущие полномочия и endpoint, использует один транспорт и общий абсолютный бюджет ≤120 секунд: 1 MiB/ответ, 8 MiB суммарно, 256 запросов, 1000 строк/ответ и 10000 суммарно. Baseline и по 2–3 раунда forward/inverse не сбрасывают лимиты. Подтверждённый ping 3/0 не запускает traceroute. Парсер принимает точный native BGP session ID и прямой next-hop; recursive next-hop и полнота log interval остаются unknown. Это внутренний read-only слой с securityProof:false: подписанный полный checkpoint и автоматический inverse не включены. Process-local запрет повторного reader имеет предел 1024 source operations и не заменяет durable runner admission после рестарта. + +Новый PostgreSQL 18 comparator сравнивает логическую схему независимо мигрированных БД, отдельно сохраняет и проверяет physical fast-default metadata, отказывается при неподдерживаемых объектах и внешних зависимостях. Реальная SQL-проверка обнаружила и закрыла пропуск зависимости view через pg_rewrite. Равенство DDL не доказывает эквивалентность данных и не разрешает enrollment. Durable startup recovery/control namespace и подключение comparator к dispatcher остаются открыты. CHR, production БД и деплой на этом этапе не затронуты; 9 pending не изменились. + +Актуальный реестр: **353 инструмента; 191 mediated / 62 excluded / 9 pending из 262 REST-маршрутов**. Полный план остаётся открытым. Проверки, хеши исходников и границы приёмки: [протокол сетевого управления](mcp-network-management-acceptance-v1.json). + +[Полный common poll, реальные writers и private reservation store проверены](mcp-poll-reservation-acceptance-v1.json). Новый `createDurableServerPollSession` собирает production SQL/native adapter, durable journal и maintenance lease из одной фабрики: все транзакции используют backend, удерживающий исключение writers. На отдельной PostgreSQL и loopback HTTP прошли **4 GET, 148 maintenance effects всех 12 parents и 152 публикации**, включая имя/cache/snapshot, точную очистку старых raw и удаление только своего пустого expired fixture-раздела. Обычный writer блокируется до relation SQL; повтор не делает новых GET, изменение подписи/внешнего input не подменяет план/identity/authority, отзыв блокирует запуск. Настоящий dump/restore/tamper прошёл. Срок 120 секунд и весь footprint сохранены; HMAC purpose key теперь выводится один раз на private session, решения о допуске не кэшируются. Предыдущие deadline-отказы сохранены в протоколе; production/MCP scalability этим стендом не доказана. + +Отдельно прошли **28 настоящих SQL/HTTP-сценариев** шести collector entrypoints, замены probes и server repository DELETE: real Drizzle, 11 родителей с FK cascade, 8 pre-relation waits, 8 фактических `55P03`, 8 потерянных ACK фактически выполненного COMMIT. Минимальные `PartitionWriteOptions` — доверенный внутренний seam для SQL fault tests; HTTP JSON их не принимает. Это подтверждение writers, а не завершение ручных collector MCP-адаптеров или HTTP authentication middleware. + +Private reservation proposal/store прошёл **10 SQL-сценариев** и настоящий dump/restore/tamper: атомарные 2 generic mutation units + 2 durable profile slots, единый source-step PK с manual inverse в 0031, исходные plan/operation/profile/authority/deadline, строгий учёт legacy switch history. Некорректная/будущая/слишком большая история отказывается без allocations. Одноразовые forward/inverse allocation capabilities не выдаются из ID/JSON/read/repeated reserve/new store; forward capability появляется только после подтверждённого нового COMMIT. После lost ACK строка может остаться held, но без capability продолжение невозможно; отдельный durable SQL unknown marker не заявляется. Proven-unused/unknown capacity пока остаётся списанной на час; legacy/ledger overlap консервативно суммируется. Полный MCP, адресные модели, build и ESLint прошли; все 27 backend-профилей не повторялись. + +Открыты runtime admission на том же transaction owner без двойного debit, переход всех controller writers на общий ledger, принятие reserved slot общим inverse engine, положительный полный inverse lifecycle, activation/baseline/verifier/restart и production-схема/HTTP/MCP full poll. Store и poll assembly не зарегистрированы; controller не включён. Read-only bootstrap-аудит дополнительно выявил cross-restart migration/import uncertainty и restart consumers после ошибки restore migrations; durable control и его restart gates ещё не реализованы. CHR, production БД и деплой не затронуты; pending по-прежнему 9. Ниже сохранены предыдущие снимки. + +[Следующий этап: запись разделённых таблиц и постоянные полномочия маршрутизации](mcp-writer-routing-integration-acceptance-v1.json). Shared admission до обращения к целевым таблицам подключён к обычным SQL/Drizzle writers, flow, collectors, каскадным удалениям, DDL, миграциям, SQLite import и actual psql restore backend. CLI-пересборка NetFlow переведена на общий атомарный сервис с настоящим drain, защитами и квитанцией. Аудит сопоставил все 17 writer-групп и 12 partition parents; это покрытие исходников, а не заявление о полной SQL-приёмке каждого entrypoint. Ошибки сохранения измерения больше не превращаются в ложный offline/down sample: 27 проверок исполняемого кода collectors. + +Постоянные profile ID/version передаются только доверенным внутренним контекстом; forward request v3 и checkpoint v2 сохраняют исходные полномочия. Изменение профиля, grant, credential или management channel блокирует запись после durable intent. Сессия ручного inverse отдельно захватывает исходную authority, проверяет её на каждом authorize и ограничивает deadline исходным validUntil. Временная PostgreSQL подтвердила writer admission **12**, CLI **3**, persistent routing **11** и metadata **7** сценариев, сохранив baseline 133/8/17/10/40, полную прежнюю scoped/inverse приёмку и настоящий dump/restore/tamper. Финальная дельта захваченной inverse authority проверена отдельно: 3 поздних изменения, 3 durable intent, ноль PATCH/HTTP; полный inverse lifecycle после этой дельты заново не заявляется. Существующий SQL-тест классификации CLI, полный MCP, IPsec, module-completion, адресные модели, build и ESLint прошли. Все 27 backend-профилей в этом этапе не повторялись. + +Открыты production assembly/миграция full poll, фактическая SQL-приёмка каждого runtime writer, порядок конкурентного обычного DDL, durable import quarantine между рестартами и quiescence всех реплик при restore. Controller, reserved inverse budget и автоматическое восстановление не включены. Новые маршруты не зарегистрированы; production БД, CHR и деплой не затронуты. Следующие абзацы описывают предыдущие снимки; их ограничения уточнены новым протоколом выше. + +[Проверен настоящий diagnostic runner и постоянный routing authority validator](mcp-runner-routing-authority-acceptance-v1.json): все шесть диагностик прошли dispatcher → approval → apply → leased runner; 8 SQL-сценариев проверили actual operation/steps, полные страницы, новый observer credential и отмену. До execute отмена теперь даёт rejected/cancelled с нулём этапов без needsReview; во время отправки — unknown и quarantine без повтора. Отдельно прошли 17 SQL-сценариев profile authority с нулём simulated PATCH после 16 изменений после durable intent, 40 SQL-сценариев durable poll journal и 10 проверок SQL lease. Всё вместе прошло настоящий dump/restore/tamper; базовые 133 SQL-сценария сохранены. Routing authority пока не встроен в controller/native checkpoints; схемы poll journal и writer triggers применялись только во временной БД. + +Проверка lease обнаружила реальную границу: row trigger получает advisory lock после relation lock, поэтому один trigger не обеспечивает безопасный DDL. SQL зафиксировал ожидаемый 55P03 через LOCK NOWAIT без DROP и положительный early-admission случай. Инвентаризированы 12 partition parents и 17 writer-групп, включая Drizzle, flow, DDL и FK cascade. Следующий обязательный шаг — shared admission **до SQL на том же backend**, включая каскадные удаления и обслуживание разделов. Production-активации неполного барьера нет; полный poll сохраняет весь maintenance footprint. + +[Принят слой журнала серверной диагностики](mcp-server-diagnostics-journal-acceptance-v1.json): миграция 0035, **114 настоящих SQL-сценариев** и dump/restore/tamper на отдельном PostgreSQL. Проверены реальные MCP operation/connection/credential и REST JWT/local identities, CAS, последовательные stages, неизменяемые encrypted receipts и quarantine после lost COMMIT ACK. Common executor прошёл 80 моделей, authority — 18, injected journal — 47; полный MCP model profile, build и ESLint прошли. Полные публичные результаты до 128 KiB передаются страницами с SHA-256 без усечения Unicode/массивов. API страниц пока не подключён. GET admission проверен на loopback и действует перед созданием запроса; DNS/TLS/socket queue следуют после него. + +[Следующий слой общих adapters и отмены проверен отдельно](mcp-server-diagnostics-adapters-acceptance-v1.json): **133 SQL-сценария** и настоящий dump/restore/tamper; все шесть REST-диагностик прошли loopback HTTP через common executor и SQL journal с синтетическими ответами. REST сохраняет собственные JWT/local identities, scoped BW fallback, DNS CNAME и MTU output; MCP adapter закрепляет реальный operation/step и полный private binding. Историческое чтение сохраняет исходную подписанную identity и использует текущий read-допуск; инертные модели проверили новый credential той же connection после истечения плана. Миграция 0036 и SQL-запросы отмены проверены; runtime получил opt-in AbortSignal и отдельные diagnosticProgress/completedServiceSteps при confirmedWrites=0. Модели MCP adapter — 146 проверок, REST-контракт — 140; полный MCP/IPsec/module-completion профили, build и scoped ESLint прошли. + +Это ещё не завершение `/probes/run`: остаются production-подключение storage admission/quota/retention, замена существующего REST handler, регистрация MCP run/read/cancel, production key provisioning и HTTP transport-приёмка зарегистрированных маршрутов. Разрешение на GC получено; его исходники и изолированная SQL-приёмка описаны в актуальном этапе в начале плана. Очистка действующей БД не выполнялась. Server poll получил production dependency adapter всех четырёх GET и полного partition maintenance; durable journal и полный writer admission ещё нужно подключить. Новые маршруты не зарегистрированы, production-схема не применялась и счётчик pending не изменился. + +[Новый протокол runtime admission и восстановления](mcp-reservation-runtime-bootstrap-acceptance-v1.json): на отдельной PostgreSQL прошли **15 сценариев** атомарного admission через `applyPlan` — операция, 2 mutation units, 2 profile slots и общий source-step slot записываются на одном backend без двойного debit. Повторное применение и lost COMMIT ACK не выдают capability продолжения. Полный manual inverse SQL/HTTP и dump/restore/tamper прошли; исправлена найденная регрессия подписи: отсутствующие optional reserved-поля больше не меняют fingerprint ручной квитанции. Reserved engine использует тот же 0031 PK и общий исполнитель; положительный autoRecovery lifecycle и его запуск остаются закрыты context guard. Отдельная повторная IPsec delete/pool-приёмка также прошла. + +Добавлена **инертная модель полного сетевого verifier: 46 тестов**. Она различает installation table и probe VRF, требует точные route/next-hop/BGP/BFD/probe/log identities, две стабильные полные выборки, полноту log interval и общий бюджет ≤120 секунд между forward/inverse. Любая неопределённость преобладает над failure. Это классификация переданных наблюдений, не native reader, подписанный checkpoint, authority или разрешение на откат; per-response wire/page limits ещё должен обеспечивать native adapter. + +Для restore введена общая process-local защёлка: после начала target psql NetFlow/планировщик/RIPE не запускаются до успешных миграций, включая последующий export в том же процессе. Шесть тестов реальных entrypoints с инертным I/O, независимые 15 адресных проверок и настоящий owned terminal restore прошли. **Durable startup recovery не реализован.** Подготовлен точный манифест 36 SQL-файлов (14 сценариев); dispatcher, независимый schema comparator и control namespace ещё не подключены. Финальные MCP-профиль, модели, backend build и lint прошли; все backend-профили не повторялись. Деплоя, production DDL и обращений к CHR не было. + +Ниже сохранены предыдущие этапы; их открытые пункты нужно читать с учётом нового протокола. Общий план остаётся открытым, реестр и 9 pending не изменились. + +- [x] **IPsec init v3**: общий REST/MCP executor всех полей мастера, подпись только новых ACK-owned сертификатов, source/authority fences, полный raw preflight и обязательная история. Изменение существующего IPv4-пула учитывает DHCP, PPP, Hotspot и next-pool; внешние потребители блокируют план. +- [x] **IPsec revision restore v1**: строгие schema/fingerprint/xmin, точные записанные поля и empty clears, сохранение PSK включая пробелы, проверка PKI/profile/proposal/group/NAT/pool зависимостей и отсутствие replay. Незаданные native-поля существующего mode-config сохраняются. Если старый snapshot не позволяет восстановить отсутствующую policy или mode-config, legacy certificate-поле либо unknown auth capability, операция отказывается до записи; утраченные ключи не генерируются заново. Непустой native default для записанного пустого значения не принимается как успешное восстановление. +- [x] **WireGuard revision restore v1**: полная форма интерфейсов/peers/IPv4/IPv6, encrypted desired keys, явный inventory удалений, staged disable/enable, private per-command proof/read-back и обязательная история. Ключи в модель не выдаются; сверка использует только точный сохранённый VERIFIED checkpoint. +- [x] **IPsec server delete v2**: после отдельного разрешения применён шестифайловый патч предохранителей IPv4-пулов. Сценарий удаления не расширен: внешние DHCP/PPP/Hotspot/next-pool потребители запрещают удаление, старые планы v1 не выполняются. Внешняя RADIUS Framed-Pool authority не выводится из native-конфигурации. +- [x] **VRF production preparation**: явный productionAdmission:true на существующих typed tools сохраняет прежнюю подготовку простых планов без флага. Encrypted ticket сохраняется вместе с реальным планом в одной транзакции; ключ выводится из durable private material с отдельным purpose. Admission/private domains v2 исключают только скалярную telemetry соответствующего каталога; неизвестные и вложенные поля остаются sealed. 86 моделей отказа, 7 runtime SQL-сценариев и 11 SQL-контрактов tickets пройдены. Unknown claim не перезаписывает завершённую операцию. +- [x] **VRF transition model**: 62 проверки шести lifecycle-фаз и произвольных IPv4 /23, IPv6 /56. Это чистый classifier для доказанных bridge effects; reenable/LL revival требуют непрерывного checkpoint, disable ограничен наблюдавшимся циклом. Production executor и native companion admission не включены. +- [x] **Мониторинг частичного результата**: bounded nativeProgress отдельно показывает ACK и подтверждённые read-back команды; счётчик завершённых service steps сохранён отдельно. Private error payload не публикуется. + +SQL/HTTP использует временный PostgreSQL и локальные RouterOS fixtures. Новый fixture agent переиспользует максимум два соединения только с точным связанным loopback endpoint, сохраняет настоящий HTTP/parser/guards и не повторяет запросы; teardown восстанавливает transport. Это устраняет подтверждённый Windows EADDRINUSE в накопительном тесте. Production HTTP agent не изменён; его нагрузочная и живая CHR-приёмка этим протоколом не заявляются. Сборка, профили MCP/IPsec/WireGuard, новые модели, scoped ESLint и настоящий dump/restore/tamper проверены в указанном отчёте; все 27 backend-профилей не повторялись. Деплоя, live collector или изменений CHR не было. + +Остаются **9 REST-сценариев**: два экспорта существующих IPsec-сертификатов и семь ручных collectors (server/API ping/probes, traffic, uptime/group/speed-test). Persistent routing authority/reserved inverse/controller, production VRF executor/recovery/forwarding, внешние AI-клиенты, live portal sender/UI и независимая трёхузловая приёмка остаются в полном плане. Доступны только два CHR. + +Подготовлены отдельные, ещё не зарегистрированные основы server poll, шести видов server probes и экспорта существующих IPsec-сертификатов. Для poll ещё нужны настоящий SQL journal и global writer lease; общий reader/DDL adapter пока проверен на инертных зависимостях. Для probes нужен adapter, связывающий принятый SQL journal 0035 с REST/MCP, и перечисленные выше проверки полного сценария. Для экспорта нужны отдельное разрешение на существующие ключи, полноценная SQL/HTTP-приёмка и подключение портала. Production export и его artifact readers выключены. Эти исходники не закрывают ни один из девяти pending-маршрутов. + +## Исторические этапы + +Прежние счётчики и ограничения ниже относятся к соответствующим снимкам; текущий реестр и протокол приведены выше. + + +Актуальный итог этого этапа: [объединённый протокол](mcp-modules-consolidated-acceptance-v1.json), **350 инструментов; 188 mediated / 62 excluded / 12 pending из 262 REST-маршрутов**. Полный план остаётся открытым. + +[NetFlow принят с финальными поправками](mcp-netflow-settings-acceptance-v2.json): `netflow.settings.update` **v3**, полный `{settings: …}` из десяти полей, резерв **4 публикации** при двух фазах. SQL подтвердил отказ старого v2 и бюджета 3 до записи. До ACK нет timer/packet/enrichment и автоматического повтора startup; normal runtime после ACK отделён от UDP readiness. [IPsec CREATE принят с точным bounded reader](mcp-ipsec-user-create-acceptance-v2.json): приватный PKCS12 читается по проверенному native ID без поиска нового файла по имени; поток, размер, общее время и допуск ограничены. v1 этих двух отчётов сохраняется как исторический снимок, текущие ограничения и поправки описаны в v2. + +Основная MCP SQL/HTTP-регрессия завершилась с кодом 0 вместе с настоящим dump/restore/tamper на baseline до последних двух поправок. Финальные дельты отдельно прошли свежие целевые SQL/HTTP, весь MCP model profile, NetFlow settings/file profiles, backend build и ESLint. Это не повтор всех 27 backend-профилей и не живая CHR/UDP-приёмка. VRF reader/admission/tickets прошли 76 моделей и 11 SQL-сценариев; native execution пока выключен. Деплоя и новых обращений к CHR не было. + + +Текущий подтверждённый срез: **350 инструментов; 188 mediated / 62 excluded / 12 pending из 262 REST-маршрутов**. Полный план открыт. Ниже сохранены исторические этапы; их прежние счётчики и ограничения относятся к указанным снимкам. + +- [x] [Полные настройки NetFlow и ключи](mcp-netflow-settings-acceptance-v1.json): общий REST/MCP executor всех десяти полей, включая no-op/display-only restart; full-config CAS, постоянные phase intents и внутренние main/worker lifecycle checkpoints. Старый handler из четырёх полей заменён общим executor; актуальная версия v3 и бюджет 4 описаны выше (промежуточный v2 исторический). `mm_netflow_host_keys_generate_plan` создаёт только неполную пару и выдаёт публичный ключ. SQL/HTTP проверяет настоящий disabled main flush; worker startup/shutdown проверены инертными моделями. Живой UDP worker отдельно не принимался. +- [x] [CREATE клиента IPsec](mcp-ipsec-user-create-acceptance-v1.json): PSK/certificate/static IP, полный private proof и dependency closure, подпись/export/cleanup без повторов, обязательная история. После отдельного разрешения пользователя PKCS12 выдаётся владельцу с network-write через зашифрованный портал на 15 минут только при `privateKeyExport=true` и human approval; модель получает квитанцию. Loopback SQL/HTTP всех веток и owner download прошли; настоящие CHR/SA/forwarding не проверялись. +- [x] [Production VRF raw reader и durable tickets](mcp-vrf-production-ticket-acceptance-v1.json): 17 raw-каталогов и конкретные parent/peer/VTEP зависимости, HMAC admission, реальные encrypted plan bindings, отдельное соединение для одноразового claim. 76 моделей отказа и 11 SQL-сценариев прошли. Это precondition, **native execution не включён**; runtime journal/key provisioning и production transition classifier остаются открытыми. + +Целевые SQL-приёмки, настоящий dump/restore/tamper, весь MCP model profile, IPsec/settings/purge/ownership profiles, backend build и адресный ESLint прошли. Основная SQL-регрессия baseline затем завершилась; финальные дельты и точные границы проверки приведены в объединённом протоколе выше. Все 27 backend-профилей повторно не заявляются. Деплоя, запуска живого collector и обращений к CHR в этом этапе не было. + +Оставшиеся REST-сценарии: IPsec certificate export, user certificate export, server init и revision restore; ручные servers/API-ping/probes/traffic/uptime/speed-test collectors; WireGuard revision restore. Persistent routing profile authority/reserved inverse/controller, production VRF native/forwarding, внешние AI-клиенты/portal sender/live UI и независимая трёхузловая приёмка остаются в исходном объёме; доступны два CHR. + + +- [x] [Удаление IPsec-сервера принято](mcp-ipsec-server-delete-acceptance-v1.json): `mm_ipsec_server_remove_plan` использует общий REST/MCP executor с полным ordered manifest, защитами foreign references и объявленными CA cascade. Profiles/proposals и чужие объекты сохраняются; каждый DELETE проходит private stage proof, read-back и checkpoint, затем обязательную историю. Proof v2 исключает только certificate expires-after; даты, trust, CRL, ссылки и неизвестные поля сохранены. Старые планы блокируются повышенными версиями действий. SQL/HTTP, весь IPsec-профиль, весь MCP model profile, build, lint и настоящий dump/restore/tamper прошли. Реестр: **348 инструментов; 185 mediated / 62 excluded / 15 pending из 262 REST-маршрутов**. Настоящий CHR cascade/forwarding остаётся отдельной приёмкой; полный план открыт. + +Параллельно подготовлены [основы модулей NetFlow settings и production VRF](mcp-parallel-module-foundations-v1.json): 43 чистые модели executor и 65 моделей отказа admission соответственно. NetFlow ещё требует CAS/intents и внутренних collector fences; VRF — raw reader, durable ticket store, transition classifier и native executor. Runtime этих компонентов не подключён; это не закрывает ожидающие REST/VRF-сценарии. + +- [x] [Полный PATCH клиента IPsec принят](mcp-ipsec-user-update-acceptance-v1.json): `mm_ipsec_user_edit_plan` использует общий REST/MCP executor для имени, static IPv4/возврата к пулу, PSK, Remote ID и disabled, включая cert-only promotion/ignored fields, shared/foreign mode-config и историю. PSK в MCP передаётся только через привязанную к владельцу/серверу/клиенту ссылку. Каждый ACK проверяет точную сохранность полного private state; отзыв PSK-reference после первого ACK блокирует следующую команду. Исправлена ветка пустого staticIp: 400 до изменения имени. SQL/HTTP parity, весь IPsec-профиль, весь MCP model profile, build, lint и настоящий dump/restore/tamper прошли. Реестр: **347 инструментов; 184 mediated / 62 excluded / 16 pending из 262 REST-маршрутов**. Живые CHR и forwarding этим прогоном не проверялись; полный план остаётся открытым. + +- [x] [Native IPsec dependency guards приняты](mcp-ipsec-native-closure-acceptance-v1.json): `mm_ipsec_peer_create_plan` v4 закрепляет точный/default profile и существующие ссылки; `mm_certificate_update_plan` v3 учитывает цепочку сертификатов, identities и peers и требует одновременно network-write и certificates-write. Оба входа требуют human approval и полного private proof вместо усечённого generic snapshot. Смена подключения блокируется до первого GET, старые прямые native/inverse шаги — до SQL и native access. SQL/HTTP, прежние IPsec DELETE/PATCH, настоящий dump/restore/tamper, весь MCP model profile, build и lint прошли. Ожидаемый rename ссылок проверен на loopback; настоящий CHR rename остаётся открытой приёмкой. Реестр без изменений: **346 инструментов; 183 mediated / 62 excluded / 17 pending**. + +- [x] [REST/MCP rebuild NetFlow принят](mcp-flow-rebuild-adapter-acceptance-v1.json): `mm_netflow_facts_rebuild_plan` требует human approval и полного global/server scope; `mm_netflow_facts_rebuild_reconcile` сверяет историческую квитанцию без повтора записи. На временном PostgreSQL прошли actual main-writer drain, атомарные факты/событие/квитанция, scope/protection guards, новый credential и JWT REST parity. Backend build, весь MCP model profile и scoped lint прошли. Реестр: **346 инструментов; 183 mediated / 62 excluded / 17 pending из 262 REST-маршрутов**. Живой worker отдельно не запускался, деплоя не было; полный план остаётся открытым. + +- [x] [Flow engine wiring принят](mcp-flow-engine-wiring-acceptance-v1.json): после отдельного разрешения применён цельный патч engine/worker/purge. Backend build, native-conservation SQL/HTTP, весь traffic-flow профиль, SQL logs и настоящий dump/restore/tamper прошли. Общий rebuild проверен на настоящих partitioned таблицах с явной no-op drain-фикстурой: дневная агрегация, rollback обоих кубов, поздние защиты/отзыв и чтение известной квитанции до drain. REST/MCP принят следующим этапом выше; проверка живого существующего worker остаётся открытой; деплоя и запуска collectors не было. + +- [x] [Изолированный native VRF lifecycle v5 принят](mcp-chr-vrf-companion-native-v5-acceptance.json): отдельный новый fixture подтвердил create/rename/disable/re-enable/member transfer/delete, 18 ACK с read-back и штатную очистку. Независимый GET без исключений подтвердил ноль тестовых объектов и все 34 исходных хеша 17 каталогов; SSH закрыт. Base-контракт содержит 218 моделей отказа. Generic production MCP companion admission и forwarding этим прогоном не проверены и остаются открытыми. + +Отдельный [SQL-контракт flow writer](mcp-flow-write-transaction-pg-acceptance-v3.json) прошёл на временном PostgreSQL: атомарность двух shadow-кубов, события и квитанции, отсутствие повторного тела, lost COMMIT ACK, реальный advisory-lock barrier и отказ по отзыву до COMMIT. Это отдельный writer-контракт; последующая REST/MCP-приёмка приведена выше. `npm --prefix backend run test:mcp:vrf-cleanup` теперь запускает все три cleanup-контракта (50 + 53 + 94 модели отказа); весь профиль прошёл. + +Сейчас в работе: OSPF template listing order сохраняется в projected snapshots и общем cost writer; snapshots/source-domain повышены до v3, optimizer до v9, template C/U/D до v5/v4/v3. Старые v2 snapshots не проходят prefix admission. [SQL-приёмка OSPF order завершена](mcp-ospf-template-order-acceptance-v1.json): main MCP SQL/HTTP, OSPF-профиль, SQL logs, настоящий dump/restore/tamper и весь MCP model profile прошли; до dispatch reorder даёт rejected/0 PATCH, после ACK — unknown/1 PATCH/0 VERIFIED. Все 27 backend-профилей не повторялись. Shared flow executor и writer fencing подключены после отдельного разрешения; подтверждённая регрессия приведена выше. REST/MCP adapters приняты последующим этапом выше. + +[Native VRF v4](mcp-chr-vrf-companion-native-v4.json) подтвердил create/rename/disable/re-enable с полным read-back. Transfer получил ACK, затем proof остановился: RouterOS переназначил ID между собственными semantic connected routes и убрал LL/LL-route нового временного участника. [Первый GET](mcp-chr-vrf-companion-readonly-diagnostic-v4.json) и [поздний GET](mcp-chr-vrf-companion-readonly-diagnostic-v4-late.json) подтвердили одинаковую форму. В исходном lifecycle DELETE VRF не запускался. [Отдельная очистка завершена](mcp-chr-vrf-companion-transfer-cleanup-result-v1.json): все семь ACK-owned объектов удалены без повторов команд, с pinning present/absent kernel choices и ACK-only checkpoint advance; независимый финальный GET подтвердил ноль тестовых объектов и все 34 исходных хеша без исключений. Старый runner не возобновляется; полный lifecycle этим прогоном v4 не принят. + +Предыдущая зафиксированная дельта прошла [проверку снимка 383 файлов](mcp-vrf-accumulator-regression-v1.json): native-conservation SQL/HTTP, весь профиль traffic-flow (включая существующий rebuild), SQL logs, настоящий dump/restore/tamper, весь MCP model profile и backend build — код 0. Выделен чистый instance-based fact accumulator с сохранением нормализации, ASN200/tuple8000 и stable ties; прежний live API использует один экземпляр. Это подготовка атомарного rebuild, сам MCP-адаптер ещё не включён. VRF v3 содержит ACK-bound полные kernel evidence и 138 моделей отказа. [Новый native-прогон](mcp-chr-vrf-companion-native-v3.json) подтвердил создание семи объектов и rename с read-back, затем остановился после ACK disable на неподтверждённой disabled-форме; re-enable/transfer не выполнялись. [GET-диагностика](mcp-chr-vrf-companion-readonly-diagnostic-v3.json) показала удаление dynamic table, сохранение выключенного virtual interface и возврат адресов/маршрутов в main. [Отдельная очистка завершена](mcp-chr-vrf-companion-disabled-cleanup-result-v1.json): удалены все семь ACK-owned объектов без повторов PATCH/DELETE; независимая GET-проверка подтвердила ноль тестовых объектов и все 34 исходных хеша без исключений. Полный native lifecycle не принят. + +- [x] `POST /api/traffic/flow/rebuild-facts`: общий REST/MCP executor и отдельная read-only сверка приняты; [протокол](mcp-flow-rebuild-adapter-acceptance-v1.json). Права/полный scope/human approval, строгий main drain, атомарная публикация кубов/события/квитанции, source/protection guards и unknown без replay проверены. Приёмка существующего живого worker остаётся отдельной. + +Этап в работе: общий native verifier проверяет назначенный CREATE ID, состав и неизменность соседних объектов в видимой проекции; сохранён порядок 11 каталогов. Source-bound snapshots v2 и expected-prefix checks защищают подготовку, каждый шаг и dispatch после durable native intent. Целевая SQL/HTTP-приёмка, 54 HTTP-модели, ECMP+hash/singleton inverse и dump/restore прошли. OSPF builder исправлен со свежим source-bound capture (v8); SQL/HTTP подтвердил полный owner graph и VRF context, [измерив 93 HTTP-запроса](mcp-ospf-read-budget-observation.json) на один PATCH. Web CREATE adapter исправлен: CREATE не передаёт id даже как undefined, строгая схема сохранена. [Повторная объединённая SQL-регрессия снимка 379 файлов](mcp-native-conservation-regression-v1.json) завершилась с кодом 0: main integration, профиль OSPF, SQL logs, настоящий pg_dump/restore и tamper refusal. Весь MCP model profile и backend build также прошли. Все 27 backend-профилей этой дельты не повторялись. + +- [x] Вложенный polling OSPF устранён: [статический аудит](mcp-ospf-read-amplification-audit.json) вывел 1232 GET на один cost PATCH в трёхузловой HTTP-фикстуре из-за вложенного полного graph guard. Это не native timing measurement. Исправление внесено: grant/source перед каждым чтением сохранены; полный граф проверяется на пяти границах, последним перед dispatch и после ACK до VERIFIED. Модели source ABA/revocation и post-ACK classification прошли; успешная объединённая SQL-регрессия подтвердила 80 GET + 12 read-only POST + 1 PATCH = 93 HTTP-запроса вместо 1232 GET. Это трёхузловая HTTP-фикстура, не native forwarding-приёмка. + +- [ ] Общий этап пока не принят: VRF требует typed companion-контрактов для автоматически перемещаемых/удаляемых connected routes, таблиц и virtual interfaces. [Native-v2](mcp-chr-vrf-companion-native-v2.json) на RouterOS 7.24.5 подтвердил создание двух bridge, четырёх адресов и VRF, затем остановился на неизвестных kernel companions; rename/toggle/transfer не выполнялись. [Отдельная очистка завершена](mcp-chr-vrf-companion-cleanup-result-v1.json): все семь ACK-owned объектов удалены без повторов команд и без прямого удаления kernel-объектов. Первый cleanup остановился после ACK IPv4 DELETE из-за изменения собственного bridge status; GET-диагностика и отдельный guard точного наблюдённого перехода позволили проверить отсутствие удалённых ID и удалить оставшиеся три объекта. [Независимая заключительная GET-проверка](mcp-chr-vrf-companion-cleanup-final-verification-v1.json) подтвердила ноль тестовых объектов и совпадение всех 34 исходных хешей 17 каталогов уже без исключений. Исходный unknownWrite latch и отчёт не изменены. Полный native lifecycle и generic MCP companion admission остаются открытыми. + +Предыдущая принятая дельта: устранена подмена ID в native CREATE. Все 30 CREATE-схем запрещают `id`; старые некорректные шаги блокируются до отправки, проверка использует только acknowledged `.id`, inverse выбирает единственную добавленную строку. CREATE-версии повышены, recovery — v4. [SQL/HTTP-протокол](mcp-native-create-id-acceptance.json): код 0, нормальный create/inverse сохраняет существующий объект, legacy poison и старые версии блокируются, ignored/lost не повторяются. Весь профиль MCP, build и ESLint прошли; целевая recovery/maintenance SQL-регрессия на v4, включая primary-down alternate, и настоящий dump/restore/tamper завершились с кодом 0. Независимое read-only review не нашло ошибок в этой дельте. Полные 27 профилей после этой дельты не повторялись; принятый предыдущий прогон указан ниже. Реестр остаётся **344 инструмента; 182 mediated / 62 excluded / 18 pending**. + +- [ ] Усилить общий verifier: новый assigned ID должен отсутствовать before, изменения должны сохранять соседние объекты и порядок правил. Добавить expected-prefix proof между шагами и полные private/source/dependency contracts. Эта дельта не доказывает whole-catalog conservation. [Статический аудит до исправления ID](mcp-native-write-hardening-audit.json). + +Предыдущий этап: SLA после записи и два IPsec-адаптера прошли целевую SQL/HTTP-приёмку с кодом 0, включая настоящий dump/restore/tamper. Реестр: **344 инструмента; 182 mediated / 62 excluded / 18 pending из 262 REST-маршрутов**. [Целевой протокол](mcp-routing-sla-ipsec-focused-acceptance.json). Первый полный прогон остановился на дубликате разрешения в ACME-фикстуре (код 1); дубликаты исправлены только в двух тестовых фикстурах. [Повторная полная регрессия нового снимка 360 файлов](mcp-routing-sla-ipsec-acceptance.json) завершилась с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper прошли; весь план остаётся открытым. + +- [x] `mm_route_optimizer_plan` v6 проверяет свежие SLA/preflight до PATCH, в том числе после durable intent. После PATCH конфигурационный read-back и CONFIG_APPLIED не зависят от новых SLA; исходная path-row identity, rights/policy/source/protection proofs сохраняются. Подписанные stale/100%-loss samples после записи, failed/unknown/passed probe, reconciliation и no replay проверены. Изменение прав/защиты/epoch/preflight row после записи по-прежнему запрещает подтверждение. +- [x] `mm_ipsec_certificate_remove_plan` / `network.ipsec.certificates.delete`: общий REST/MCP DELETE любого выбранного сертификата, force только для peer/identity ссылок. CA children, CRL true/yes и неоднозначный issuer не обходятся. Общий guard используется и при certificate cascade удаления пользователя. Private proof, per-dispatch fence, exact read-back, обязательная managed-only история и exact generic VERIFIED проверены; unknown не повторяется. +- [x] `mm_ipsec_peer_edit_plan` / `network.ipsec.peers.update`: все REST-поля, raw empty strings, same-value PATCH и history-only `{}`. Certificate presence возвращает 400 до чтений. Business MCP/native поддерживают comment; REST сохраняет прежнюю схему без comment. Ожидаемое переименование native-ссылок проверяется whole-state proof; mock без обновления ссылок даёт partial. Настоящее native rename поведение ещё не принято. +- [x] Native certificate DELETE, peer DELETE/PATCH имеют версию 2 и входят в общие guards; business scope нужен отдельно до native reads. Для native certificate также сохраняется certificates-write. Известные секреты, отражённые в dependency labels, маскируются; отражённое в native certificate name закрытое значение не материализуется в MCP-план. Проверены REST/MCP/alias/grant/protection/ABA/history/tamper и потеря ответа. +- [x] Шесть IPsec model modules, весь `test:mcp`, build, адресный ESLint и standalone REST history/mutation прошли. Первый запуск MCP-моделей в sandbox получил loopback EACCES; повтор с доступом к локальным fixtures прошёл без product patch. UI не менялся; предыдущие UI hashes сверены. Реальные CHR в этой серии не менялись. +- [x] Два generic native IPsec пробела закрыты через отдельные guarded services: certificate UPDATE учитывает issuer/child/identity/peer closure и оба права network-write/certificates-write; peer CREATE закрепляет profile и блокирует late binding. [SQL/HTTP и модели](mcp-ipsec-native-closure-acceptance-v1.json) прошли. Прямой generic dispatch и старые inverses этих ресурсов больше не обходят проверки. Живой CHR rename/reference и остальной write perimeter остаются отдельными пунктами. +- [ ] Persistent profile authority/version fence, reserved scoped recovery и scoped controller остаются выключенными. Native peer rename/reference и force-certificate-delete, независимый/обратный трафик, live portal/external clients и трёхузловая приёмка остаются открытыми; доступны только два CHR. + +Предыдущий этап: WireGuard export подключён к MCP и порталу. Реестр: **342 инструмента; 180 mediated / 62 excluded / 20 pending из 262 REST-маршрутов**. [Целевая приёмка](mcp-wireguard-export-focused-acceptance.json) и [полная регрессия объединённого снимка](mcp-wireguard-export-full-acceptance.json) завершились с кодом 0: 27 backend-профилей, SQL logs, настоящий dump/restore/tamper. Две последующие коррекции прошли [отдельную целевую проверку](mcp-wireguard-export-contract-acceptance.json); повторный полный прогон после этой дельты не заявляется. Полный план не завершён. + +- [x] `mm_wireguard_export_plan` / `network.wireguard.export`: human approval обязателен даже в autonomous mode; модель получает только квитанцию. Файл зашифрован, хранится 15 минут, доступен вошедшему владельцу с network-write и действующим допуском; нужен exact generic VERIFIED checkpoint. Отзыв во время чтения, policy/source ABA, защита объекта, подмена, expiry и cancellation проверены. Ответ скачивания — private/no-store. +- [x] Общий REST/MCP reader и renderer сохраняют rsc/conf/peer-conf, JSON REST `{format, filename, content}`, native/composite peer IDs и явный includePrivateKey=true. Без него ключ интерфейса не включается; peer-conf не выдаёт приватные ключи. Безопасные имена файлов нормализуются. PSK, неоднозначные каталоги и невоспроизводимые состояния отклоняются; conf не скрывает disabled peers, несколько/динамические/специальные адреса не экспортируются как готовая конфигурация. +- [x] JWT HTTP-приёмка на синтетических ключах: равенство REST/MCP содержимого и имён во всех форматах, omitted/false opt-in, отказ anonymous/MCP bearer/foreign owner/missing permission до native reads, receipt listing без ключей. Build, ESLint, пять export model tests и inventory прошли. UI не менялся; hashes прежних UI-исходников и логов сверены, новый live browser/AI-client пока не принят. +- [x] Предыдущий full v2 завершился с кодом 1 только на старом standalone IPsec mock. [Отдельный результат](mcp-recovery-maintenance-regression-v2-result.json) сохранён; исправлена модель source/intent/native deletion/read-back в тесте, production guards не ослаблены. Повторная полная регрессия с export прошла, включая исправленный IPsec профиль, строгий ноль primary requests и SQL logs. +- [x] Две дельты read-only аудита исправлены: REST использует тот же строгий request schema и возвращает 400 до native reads; keepalive `25s`/`00:00:25` разбирается общим peer codec. Шесть моделей, SQL/HTTP всех форматов и approved owner download, dump/restore/tamper прошли с кодом 0. Первый расширенный fixture упёрся в квоту восьми пакетов; duration-сценарий включён в уже существующий пакет, production квота не менялась и unknown operation не повторялась. + +Ручной scoped Community inverse, заранее закреплённый независимый management transport, удаление IPsec users и глобальная очистка NetFlow прошли целевую локальную SQL/HTTP-приёмку с настоящим dump/restore/tamper и терминальным кодом 0. Реестр: **341 инструмент; 179 mediated / 62 excluded / 21 pending из 262 REST-маршрутов**. [Целевой протокол](mcp-recovery-maintenance-focused-acceptance.json). Проверки production core этого этапа также прошли в объединённой полной регрессии export; исходный full v2 был остановлен только устаревшим test fixture. Для Home → JumpHost → ExitNode доступны только два CHR; независимая трёхузловая приёмка остаётся открытой. + +- [x] Ручной inverse использует только зашифрованный исходный operation/step и exact native body/ID. Нужны human approval и свежая A-проба уже в B-контексте. Один исходный step занимает один durable inverse intent; срок не более 120 секунд, продление подменой времени запрещено. RECOVERY_CONFIG_APPLIED и RECOVERY_VERIFIED разделены, полный reconcile требует exact generic VERIFIED; unknown не повторяется. +- [x] Отдельный management transport закрепляется до потери primary. После потери primary alternate read/PATCH/reconcile не обращаются к его адресу; logical source epochs, license identity, channel expiry/replacement/revocation и live policy проверяются. Конкурентные планы, lost/ignored/collateral, late protection/ABA, future/stale source и path timestamps проверены локально. +- [x] Полное shared REST/MCP удаление IPsec users с optional certificate/mode-config cascade, сохранением общих ссылок/CA/server cert, per-command intents/read-back, prefix checkpoints и managed-only историей. Ошибка истории и совпадение после lost response не становятся VERIFIED. +- [x] Полный shared REST/MCP NetFlow purge: acknowledged shutdown и clean exit всех current/retiring workers, permanent failed-stop latch, generation fence против позднего spawn; шесть таблиц в одном transaction, настройки/ключи/peers сохранены. Глобальный retention maintenance явно показан в review; каждый DROP входит в утверждённый inventory/budget, новый раздел не удаляется. Lost COMMIT не повторяется; normal restart может принять новые flows. +- [x] 18 targeted model tests, backend build, адресный ESLint и inventory прошли. После HTTP-приёмки public review дополнен более коротким execution deadline, проверенным моделью; полный прогон проверит финальный снимок целиком. UI не менялся, новые реальные CHR-команды не выполнялись. +- [ ] Reserved scoped recovery, привязка версии persistent profile у native transport и scoped controller остаются выключенными. Оставшиеся 21 REST-адаптер, ожидающие разрешения collectors и подключения разрешённого key-export, независимая/packet/return/трёхузловая/NAT/RPF/hash/PSK-SA приёмка, live portal/external clients и исторические route hashes остаются в плане. + +Пользователь явно разрешил оба способа экспорта WireGuard private keys по includePrivateKey=true: REST и MCP-план с human approval, зашифрованным файлом на 15 минут и скачиванием вошедшим владельцем. Это разрешение выполнено в текущем этапе; повторно спрашивать его не требуется. + +Полный прогон v1 остановился на interference фонового GRE/BGP poll с HTTP-счётчиком. Для owned test fixtures теперь закрывается scheduler admission и дожидается завершения текущих jobs; исходное состояние восстанавливается, строгое требование нуля primary requests сохранено. Production core этого исправления не менялся. [Протокол повторного запуска](mcp-recovery-maintenance-regression-progress.json) сохранён как исторический; результат full v2 и новый объединённый прогон описаны выше. + +Уточнения следующего этапа после read-only аудита: + +- [ ] Сначала связать persistent profile ID/version с конкретным подтверждённым human grant operation и server-only authority receipt. Проверять grant/version/pause до intent и непосредственно после durable configuration.intent перед native PATCH; orchestration lease не удерживать как блокировку, которую затем повторно ждёт runtime. +- [x] После forward PATCH конфигурационный read-back и CONFIG_APPLIED отделены от eligibility/SLA уже выбранного B; это принято текущей целевой SQL/HTTP-проверкой. Network outcome сохраняется отдельно. До PATCH свежий SLA B обязателен, для inverse сохраняются SLA A и новая A-проба в B-контексте. Автоматический inverse этим изменением не включён. +- [ ] Для reserved inverse нужны отдельные stable source proof и internal runner capability: исходная операция ещё running. Публичный allowRunning запрещён. Admission атомарно резервирует оба native writes и оба profile switch slots, включая unknown attempts; новый tick/credential/restart не продлевает 120 секунд. Использовать тот же уникальный source operation/step inverse slot и exact inverse generic VERIFIED. +- [ ] Controller должен хранить expected native ID/target/dependency/source proofs и authority version, а не один candidate ID. Same-candidate foreign drift и любой unknown останавливают профиль. Новый enabled grant требует подтверждённого baseline; обычное approval профиля не подменяет дополнительные object protections. +- [x] REST/MCP PATCH клиента IPsec охватывает все поля, no-op с history, cert-only promotion/ignored fields и shared/foreign mode-config upsert с consumer/pool closure. Пустой staticIp теперь возвращает 400 до переименования identity. [Полная SQL/HTTP-приёмка](mcp-ipsec-user-update-acceptance-v1.json) прошла; expected companion references при peer rename приняты ранее на loopback, настоящий CHR rename остаётся отдельным открытым gate. +- [x] Одиночный certificate DELETE и bulk server DELETE используют раздельные полные executors. Для bulk принят полный ordered manifest, staged proof, profiles/proposals preservation, foreign mode-config → pool/peer → certificate checks, native name/ID ambiguity guards и полный certificate graph с объявленными owned CA cascade. [Приёмка](mcp-ipsec-server-delete-acceptance-v1.json) включает REST/MCP parity, false/true removeCertificates, CRL subtree, счётчик срока и неизвестные поля. Настоящий CHR cascade/interoperability остаётся открытым gate. + +Этап подтверждения конфигурации Route AI, конкурентного управления профилем и ещё двух REST/MCP адаптеров локально принят: [финальный протокол](mcp-scoped-checkpoint-adapters-acceptance.json), [предшествующая focused-приёмка](mcp-scoped-checkpoint-adapters-focused-acceptance.json). Реестр: **339 инструментов; 177 mediated / 62 excluded / 23 pending из 262 REST-маршрутов**. Полная последовательная регрессия завершилась с кодом 0: 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper. Scoped executor сохраняет зашифрованный exact `CONFIG_APPLIED` после PATCH/read-back до post-probe; он связан с operation/step/receipt, original/desired body и candidate, dependency/target proofs и endpoint epochs. Отдельная подписанная квитанция пробы обязательна для положительного результата вместе с exact generic VERIFIED. Подтверждённый сетевой отказ даёт partial, неизвестная проба — unknown; оба случая сохраняют configuration checkpoint без inverse/replay. Автовосстановление и scoped controller не включены. + +- [x] Profile save использует expected-version CAS, create не превращается в update при collision. State/pause tick учитывают version/connection/enabled; session advisory lease принадлежит одному tick профиля. Новый human grant и emergency pause не перезаписываются устаревшим tick. Транспортная привязка profile-version для уже подготовленной операции остаётся отдельной работой. +- [x] Focused SQL/HTTP, настоящий dump/restore/tamper, 9 targeted model/transport tests, backend build, ESLint и inventory прошли. `POST /api/servers/test-connection` и `DELETE /api/ipsec/peers/:serverId/:rosId` повышены до mediated после точной приёмки REST/MCP. +- [x] Полная последовательная регрессия 27 backend-профилей и SQL `/logs` завершилась с терминальным кодом 0. Исходники совпали с focused snapshot; сохранён отдельный финальный протокол, предшествующие протоколы не переписаны. + +Этап защиты native ID 24 октября локально принят: [SQL/HTTP-протокол](mcp-protection-scope-acceptance.json). Регистрация RouterOS `*ID` без `serverId` возвращает 400 до INSERT/audit; старые записи читаются по прежней схеме. Одинаковые native IDs разных серверов независимы. Поздняя scoped-защита peer после durable activation intent останавливает REST/MCP import до первого PATCH: четыре объекта остаются отключёнными, повторной записи нет. Focused SQL/HTTP, настоящий dump/restore/tamper, backend build, JWT administration model и ESLint завершились с кодом 0. Полные 27 профилей на этой небольшой правке повторно не запускались; предыдущая полная регрессия зафиксирована ниже. Реестр остаётся **337 инструментов; 175 mediated / 62 excluded / 25 pending из 262 REST-маршрутов**. Доступны только два CHR; трёхузловая приёмка остаётся открытой. + +Этап 24 октября — строгие WireGuard codecs, общий многошаговый импорт и native-проверка PATCH — локально принят: [протокол и отпечатки исходников](mcp-wireguard-import-scalar-acceptance.json). Реестр: **337 инструментов; 175 mediated / 62 excluded / 25 pending из 262 REST-маршрутов**. Два новых инструмента импорта зарегистрированы; secure export и его portal download пока не подключены. Полный план остаётся открытым. + +- [x] Bounded `.rsc`/`.conf`: 256 КиБ, до 100 peers и 100 allowed routes на peer. Непредставимые параметры/PSK, неоднозначные поля, чужие bindings и команды отклоняются. Сохраняются escaping, Unicode, multiline comments, disabled states, client fields и IPv6 endpoints. Без явного opt-in codecs не выводят private keys; отражение ключа через другие поля отклоняется. +- [x] Общий REST/MCP import executor: disabled preparation → selective activation всех peers/address/interface. MCP использует owner/purpose/server `contentRef` с purpose `wireguard-import-config`; raw config/keys не возвращаются. REST preview сохраняет совместимую форму с placeholder `[KEY]`. Copy revision, command/object budgets, per-command intent/fences/read-back, prefix checkpoints и exact generic VERIFIED reconcile реализованы; неизвестные записи не повторяются, автоматической компенсации нет. +- [x] Два focused SQL/loopback прогона завершились с кодом 0. Финальный включает настоящие REST apply/failure, совпадение native команд/body с MCP, multiline/IPv6/два peers, copy revision, бюджет каждого объекта, защиту отдельного peer после intent и потерю финального checkpoint acknowledgement. Настоящие dump/restore/tamper прошли. +- [x] Native-диагностика собственного отключённого WG подтвердила generic/WG ID и нормализацию private scalar при PATCH: public identity и clamped scalar совпали. Три неуспешные попытки сохранены отдельно; каждая завершилась отдельной подтверждённой очисткой с четырьмя fingerprints и полным private baseline proof. Partial PATCH не повторялись. +- [x] Scalar proof v2 нормализует только proof-local clone при валидном ключе и совпавшей производной public identity. Правило сверено с [RFC 7748 §5](https://www.rfc-editor.org/rfc/rfc7748.html#section-5). Версии business/native/generic update — 3, proofRevision — 2; старые планы требуют новой подготовки. [Два CHR](mcp-chr-wg-interface-update-67ca5c07-acceptance.json): basic PATCH/alias/reconcile завершились с кодом 0, восемь подтверждённых операций, по четыре fingerprints и полный private baseline proof восстановлены, SSH закрыт. Все fixture-интерфейсы оставались отключёнными. Наблюдение проведено до последующего выделения общей scalar-функции; эквивалентность её basic-update логики проверена моделями. Это не native-приёмка окончательного multi-peer import/activation. +- [x] Общая scalar-функция используется create/import prefix/preservation guards только для собственного ключа при activation. Проверены все 32 ignored-bit представления, реальные key/public rotations, opaque/foreign drift и неизменность raw source/native body. Loopback fixture действительно выполняет clamp при PATCH; SQL/REST/MCP activation принята. Версии business create — 3 и native create — 2. +- [ ] Native multi-peer import/activation, key rotation, rename children/address на финальном shared-helper snapshot и независимый трафик остаются отдельной приёмкой. +- [x] Ранее отложенный export подключён после явного разрешения пользователя; общий REST/MCP-сервис и owner portal прошли текущую целевую HTTP-приёмку. Полный прогон итогового снимка остаётся отдельным пунктом. +- [x] Полная последовательная регрессия завершилась с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper, build и ESLint прошли. Import route повышен до mediated после focused REST/MCP приёмки. UI не менялся: прежние 16 браузерных сценариев, два helper-теста и frontend TypeScript унаследованы после сверки hashes; новый live import/browser не заявляется. +- [x] Регистрация native RouterOS `*ID` требует `serverId` в write-request validation защиты. Общая схема чтения legacy-защит сохранена. HTTP проверяет 400 без INSERT/audit, одинаковые ID разных серверов и global app/probe/server references; late scoped native-peer protection после intent проверена перед activation в REST/MCP без PATCH/replay. + +Предыдущий этап 24 октября — WireGuard PATCH, пакет событий и совместимость Route AI — локально принят: [протокол и отпечатки исходников](mcp-interface-events-compatibility-acceptance.json). Повторная полная последовательная регрессия завершилась с кодом 0. Реестр: **335 инструментов; 174 mediated / 62 excluded / 26 pending из 262 REST-маршрутов**. Полный план остаётся открытым. + +- [x] `mm_wireguard_interface_edit_plan` / `network.wireguard.interfaces.update`: общий REST/MCP PATCH всех полей, owner/purpose/server key reference, primary/secondary IPv4/IPv6, rename peer/address ссылок и сохранение opaque конфигурации. Native `mm_wireguard_update_plan` и общий `mm_interfaces_update_plan` v2 делегируют этому сервису. Сторонние routing/firewall/application зависимости требуют отдельного плана. Private proof, epoch ABA, metadata-only tolerance, intent/read-back, unknown/partial/no replay и exact VERIFIED reconcile проверены SQL/HTTP. +- [x] WireGuard shared readers ограничивают native GET фактическими UTF-8 байтами до JSON parse. Общий транспорт сохраняет стандартный предел 8 МиБ, WireGuard GET использует 1 МиБ; проверены declared/streamed Unicode overflow, отмена и отсутствие повторов. +- [x] `mm_events_batch_append_plan` / `events.batch.append`: все поля REST, trimming/time/payload, серверные UUID, один атомарный INSERT, обязательное человеческое подтверждение и полный global scope. Каждое событие — отдельный object reference для лимита и защиты; один SQL INSERT не обходит objectsPerPlan. Нормализованный пакет ограничен 64 КиБ/12 уровнями и зашифрован в плане. Это данные клиентского журнала, а не подтверждения системных действий. +- [x] `mm_global_operation_reconcile` / `operations.global.reconcile`: отдельная глобальная read-only сверка, разрешение которой нужно выдать явно. Проверяются текущий полный каталог, исходное действие и connection. Портал выбирает её для глобального плана. Совпавшие события после lost acknowledgement без точного generic VERIFIED checkpoint не выдаются за успешное выполнение; сверка не повторяет запись. +- [x] Контекст Route AI допускает отключённого постороннего BGP peer только с проверенными наследованием и dormant filter references; raw зависимости остаются в HMAC. Недоступный резерв получает `candidateDiagnostics`, выбранный кандидат требует полного подтверждения; известную идентичность текущего неисправного gateway можно отделить от readiness. SOURCE_AUTH_FAILED/INVALID_NATIVE_RESPONSE, drift/cancel/budget прерывают весь контекст. +- [x] Явно обнаруживаемый legacy read-only BGP mode использует положительно прочитанную версию RouterOS и `instance: null`. Ошибка HTTP не доказывает legacy, выдуманного instance и legacy executor нет. Переход BGP к отдельным instances документирован в [актуальной](https://help.mikrotik.com/docs/spaces/ROS/pages/328220/BGP) и [прежней](https://help.mikrotik.com/docs/spaces/ROS/pages/59277470/BGP) документации MikroTik; native pre-7.20 приёмка ещё открыта. +- [x] Общая логика трёх ручных REST-сборщиков извлечена с сохранением глобального alert hook. Опциональные access guards alert engine/outbox/Telegram проверены только на имитациях; ожидание закрывает in-flight authorization poll. Никакой новый MCP runner этих эффектов не зарегистрирован. +- [x] Два полных последовательных прогона завершились с кодом 0; финальный включает учёт каждого события в objectsPerPlan и позднюю защиту отдельного UUID. Все 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper, MCP-модели, backend build, ESLint и diff check прошли. Focused v3 содержит все completion markers, но его process handle истёк до записи кода завершения: отдельный exit code не заявляется; те же сценарии подтверждены финальным полным прогоном. UI не менялся: hashes исходников и прежних логов совпали, результаты 16 браузерных сценариев, двух helper-тестов и frontend TypeScript унаследованы из предыдущей приёмки, а не выданы за новый live-прогон. +- [ ] Подключить ручные сборщики к MCP после отдельного разрешения глобальной обработки оповещений. Автоматическая проверка отклонила добавление guarded scheduler runner: он меняет alert state/outbox и может отправить уже ожидающие Telegram-уведомления, а отдельное разрешение payload/destination не было подтверждено. Отклонённый patch не применён; вопрос пользователю задан, ответ ожидается. Эти три REST-операции остаются pending. +- [ ] Native WireGuard PATCH/readiness и pre-7.20 BGP: оба CHR доступны через существующий SSH-стенд, но WireGuard-каталоги пусты. [Read-only наблюдение](mcp-chr-wg-identity-13787ac0-acceptance.json) — 10 GET, 0 native mutations, не доказательство непустой ID equivalence. Прямое REST-соединение было недоступно; SSH проверил существующий host key и закрыт после чтения. +- [ ] Scoped controller/Community recovery, оставшиеся 26 REST-адаптеров, независимые трёхузловые прямые/обратные пути, ECMP/NAT/RPF/hash/PSK/SA и live portal/client/UI. + +Предыдущий этап 24 октября — scoped Community и терминальная диагностика — локально принят: [протокол и отпечатки исходников](mcp-scoped-route-diagnostics-acceptance.json). Исторический реестр: **332 инструмента; 172 mediated / 62 excluded / 28 pending из 262 REST-маршрутов**. + +- [x] `mm_route_path_verify` v2 разделяет `community` и `control-path`. Самостоятельная проба не зависит от BGP; purpose/version и отдельный HMAC исключают её использование для переключения Community. Legacy proof без purpose требует новой проверки. Hops ограничены устройствами выбранного кандидата до чтения credentials. +- [x] `mm_route_optimizer_plan` v3: отдельный `routing.community.scoped` исполняет ручной human-approved переход в non-main table/VRF/custom input-chain. Меняется только поле `rule` одной owned записи после полного compiler roundtrip; остальные Community, native comment/opaque, соседние контексты и SQL filter metadata сохраняются. После durable intent повторно проверяются SLA, source epoch и защиты; lost/ignored/collateral не повторяются. Read-only reconcile требует точного generic VERIFIED checkpoint. +- [x] `mm_server_terminal_diagnostic` / `servers.terminal.read`: общий с REST allowlist, все 15 диагностик и 23 alias/clamp parity случая. Сохраняется `mm:servers:write`; observer с таким делегированным правом может читать. Help не читает credentials и не опрашивает устройство; произвольный CLI не принимается. Bounded transport/output, UTF-8, endpoint ABA, отмена и поздний отзыв проверены SQL/HTTP. +- [x] В UI добавлен выбор назначения пробы; `autoRecovery` явно блокирует активацию контроллера, но позволяет сохранить отключённый профиль. Все 16 fixture-сценариев и два frontend helper-теста прошли. +- [x] Focused SQL/HTTP и полная последовательная регрессия завершились с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper. Все 16 браузерных fixture-сценариев, два frontend helper-теста, MCP-модели, build, frontend TypeScript, ESLint и diff check прошли. Протокол содержит source/log hashes и неуспешные промежуточные попытки; прошлые принятые протоколы не менялись. +- [ ] Scoped controller и Community recovery: ручной executor их не включает. Остаются 28 REST-адаптеров, совместимость старого BGP и резервных контекстов, live portal/client/UI и traffic gates. Пользователь подтвердил только два физических CHR; независимая Home → JumpHost → ExitNode приёмка требует третьего устройства. Новых native-команд CHR на этом этапе нет. + +Предыдущий этап 24 октября — контексты Route AI и три сервисных адаптера — локально принят: [протокол и отпечатки исходников](mcp-routing-context-adapters-acceptance.json). Исторический реестр: **331 инструмент; 171 mediated / 62 excluded / 29 pending из 262 REST-маршрутов**. Предыдущие протоколы ниже являются историческими снимками. + +- [x] В модели и редакторе разделены installation routing-table, VRF контрольной пробы и BGP connection/instance/input-chain/transport-VRF. Редактирование сохраняет скрытые параметры; неполная BGP-привязка отклоняется. Все 14 браузерных сценариев на API fixtures и два frontend helper-теста прошли. +- [x] Bounded read-only контекст проверяет FIB, ordered VRF membership, effective BGP inheritance/AFI/shared chains, точный recursive host resolver и endpoint epochs. Path-check подписан по зависимостям и телу owned rule; изменение любого из них после измерения не признаётся свежим доказательством. Обратный путь остаётся unknown. +- [x] Приняты общие REST/MCP операции изменения ACME settings, создания WireGuard peer и human-only экспорта PostgreSQL. Старый native peer-create alias использует тот же защищённый сервис. WireGuard proof сохраняется в той же JSON-форме, что durable plan; reconcile требует точного verified checkpoint. +- [x] Focused SQL/HTTP и полный последовательный прогон завершились с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий dump/restore и tamper. Прошли 14 браузерных сценариев на API fixtures, два frontend helper-теста, MCP-модели, build, TypeScript, ESLint и diff check. В протоколе сохранены также неуспешные попытки: два конкурентных тяжёлых прогона получили неожиданный unknown, причина не доказана; production guards/timeouts не ослаблялись. +- [x] Main Community переключение проверено через human-approved runtime plan и один локальный HTTP PATCH. Исходный owned comment сохранён; публичный initial/retrieved preview не раскрывает native scripts/comments или внутренние seals. Версии switch/profile-save подняты до 2. Это локальная SQL/HTTP-приёмка, а не новая CHR/forwarding-проверка. +- [x] Ручной scoped Route AI gateway executor и exact receipt reconcile реализованы и проверены SQL/HTTP на текущем этапе выше. +- [ ] Отдельные scoped controller и recovery для non-main/VRF. Автоматическая активация пока блокируется; сохранение отключённого профиля доступно. +- [x] Локально проверены modern BGP dormant references и недоступные резервные кандидаты, а также явно обнаруживаемый legacy read-only режим; текущий этап выше содержит границы проверки. +- [ ] Native pre-7.20 compatibility и отдельный legacy write contract. Автовосстановление Community требует исполнителя с proof состояния после переключения и до его готовности недоступно. +- [x] Ручной scoped executor меняет только `rule` одного заранее подтверждённого native filter ID. Chain/comment/disabled, порядок, остальные правила и opaque поля сохраняются; глобальный `bgp-in` executor и его SQL metadata для этого сценария не используются. Original/desired target proof вычисляет сервер по исходной строке, dependency proof и фиксированные endpoint epochs проверяются непосредственно перед PATCH и после него. Shared/output/jump зависимости и неподтверждённая gateway-грамматика блокируют запись. Reconcile только читает: совпадение конфигурации и наличие точной verified квитанции показываются раздельно, потерянный ответ не приводит к повтору. Проверено текущей SQL/HTTP-приёмкой. +- [x] До 7.20 реализован отдельно обнаруживаемый read-only BGP context mode; HTTP-отказ не считается признаком старой версии, имя instance не выдумывается. Запись требует отдельного контракта и native-приёмки старой формы BGP. +- [x] Отделён контекст самостоятельных OSPF/GRE/ECMP control probes от Community/BGP: свои endpoint/VRF/interface/address proof, purpose/version и домен подписи. Path-only без BGP не принимается как Community switching proof. Все пять native diagnostic callers используют явный `control-path`; на CHR после этого изменения они ещё не запускались. Старые native-протоколы не являются приёмкой нового контекста. +- [ ] Для независимой приёмки Home → JumpHost → ExitNode нужен третий отдельный маршрутизатор. Пользователь подтвердил наличие только двух CHR; парные проверки продолжаются, трёхузловая traffic-приёмка остаётся открытой и не подменяется логическими ролями на одном устройстве. + +Повторная приёмка 24 октября — MCP UI и оба CHR: [протокол и отпечатки исходников](mcp-artifact-ui-acceptance.json). Полный план остаётся открытым. + +- [x] В карточке завершённой операции MCP доступно скачивание backup. API публикует только ограниченную квитанцию verified шага `backups.download` для актуального владельца с правом backup; проверяет подписанные метаданные, scope, credential, policy/generation, защиты и срок. Список операций не читает файл/S3 или ciphertext. Чужой администратор, unverified/unknown шаг и отозванный доступ ссылку не получают. +- [x] UI строит путь из проверенного UUID, использует портал JWT, запрещает redirects, скачивает бинарный Blob и освобождает object URL. Отмена и безопасные сообщения об ошибках проверены; provider body не отображается. Все 11 браузерных сценариев MCP и два frontend helper-теста прошли на изолированных API fixtures. +- [x] Повторены focused и общая SQL/HTTP-приёмка, настоящий dump/restore и tamper, все 27 backend-профилей и SQL `/logs`. Build, обе TypeScript-проверки, ESLint и `git diff --check` прошли. Новый projection helper включён в backend `test:mcp`, frontend helper — в корневой `test:mcp`. +- [x] Новый [GRE CHR цикл 1d8eb626](mcp-chr-gre-revision-1d8eb626-acceptance.json) завершился с кодом 0 на обоих устройствах: create/no-op/expand/restore/empty, readonly reconcile, IPv4/IPv6 конфигурация и 24 команды. Восстановлены все 16 исходных native fingerprints и полные private dependency proofs обоих устройств. Marker-объектов нет, SSH закрыт. Forwarding, IPv6 readiness и IPsec SA этим конфигурационным циклом не подтверждаются. +- [ ] Остаются 32 REST-адаптера, Route AI non-main/VRF, независимые трёхузловые прямые/обратные пути и аварийная traffic-приёмка, ECMP/NAT/RPF/hash и PSK/SA, настоящий portal sender/внешние клиенты и финальная live UI-приёмка. Исторические `c74d0d3c` route hashes и приватный HMAC прерванного `f1147896` не объявляются доказанными задним числом. + +Следующий этап Route AI по аудиту: + +- [x] Разделены routing-table, VRF контрольной пробы и BGP connection/instance/input-chain binding; совпавший gateway не определяет выбранный путь без AFI/table/binding context. Локальная приёмка — в текущем протоколе выше. +- [x] Сохраняются bounded proof таблицы/FIB, VRF membership/order, BGP effective dependencies, resolver и endpoint. Свежая path-check связана с dependency и target rule proof; обратный путь остаётся unknown до отдельной проверки. +- [x] Ручной scoped gateway executor: per-command fences/protections и original/desired read-only reconcile с exact VERIFIED receipt проверены в SQL/HTTP. Non-main автоматизация по-прежнему заблокирована с явной причиной. +- [ ] Реализовать отдельный scoped controller/recovery и пройти независимую traffic-приёмку. + +Предыдущий принятый этап 24 октября — [пять сервисных адаптеров](mcp-application-adapters-acceptance.json): **328 инструментов; 168 mediated / 62 excluded / 32 pending**. + +- [x] ACME token test, принудительный GeoIP update, обе NetFlow overlay формы, безопасный human-only backup download и полный WireGuard interface create используют общие REST/MCP-сервисы. Поля и реальные side effects сохранены; scope, human approval, защиты, секретные ссылки, source drift, intent/read-back, partial/unknown и no replay проверены локальными SQL/HTTP/named-pipe/UDP fixtures. +- [x] Reconcile overlay/WireGuard с новыми сгенерированными ключами требует сохранённого точного verified checkpoint. Совпавший после неизвестной операции граф, чужой receipt/credential/connection, отсутствующий или unverified checkpoint не объявляются успехом. +- [x] GeoIP source fences проверяют каталог, файлы/ETag/metadata; основной файл остаётся доступным до атомарной публикации. Backup пакет подписан по purpose/owner/policy/generation/source, зашифрован и выдаётся только актуальному человеку-владельцу; модель не получает backup body. +- [x] Focused профиль `npm --prefix backend run test:mcp:pg -- --application-adapters --restore`, общая SQL/HTTP-приёмка, 27 backend-профилей с новыми model/HTTP тестами, SQL logs, настоящий dump/restore и tamper приняты. Build, ESLint и inventory прошли; source hashes в протоколе. Реальные провайдеры/backup destinations не вызывались. +- [ ] GRE CHR whole-section restore остаётся открытым: [f1147896](mcp-chr-gre-revision-f1147896-auth-interrupted.json) принял цикл CHR 1, но CHR 2 остановился на 401, затем 500 при чтении для очистки. [Отдельная новая очистка](mcp-chr-gre-revision-f1147896-recovery.json) завершилась с кодом 0 и восстановила 16 исходных native fingerprints; объектов marker нет, SSH закрыт. Исходный приватный HMAC CHR 2 не переносится после удаления временного signing key/SQL; не выдаётся за доказанный. Unknown операция не повторялась. +- [ ] Полный объём сохраняется: 32 REST-адаптера, Route AI non-main/VRF, независимые трёхузловые пути и полная traffic/аварийная приёмка, ECMP/NAT/RPF/hash и PSK/SA, реальный portal sender/внешние клиенты и финальный браузерный прогон. Два исторических route hash `c74d0d3c` также остаются недоказанными. + +Срез предыдущего этапа 24 октября: [повторная приёмка после восстановления лимита](mcp-resumed-checks-acceptance.json). + +- [x] Реализован `mm_netflow_live_observe` / `traffic.flow.live.read`: общий REST live engine, 1–3 scoped uncached выборки, ограниченные источники/страницы/размер, общий с SSE лимит четырёх подписок, отмена и отзыв перед публикацией. Настоящие SQL и HTTP SSE проверки прошли; fresh exporter/forwarding не заявляются. Реестр: **323 инструмента; 162 mediated / 62 excluded / 38 pending из 262 REST-маршрутов**. +- [x] GRE source reads отменяют и дожидаются всех sibling-запросов при отказе, malformed источнике, превышении бюджета или отзыве доступа. Необязательные PSK capability/get 400/404 допускают явный sensitive print; 401/cancel/deadline/revoke запрещают следующие запросы. Проверено настоящим HTTP, без команд записи. +- [x] Все 27 backend-профилей, SQL `/logs`, общая MCP SQL/HTTP-приёмка, dump/restore и tamper завершились с кодом 0. Финальный GRE query/fallback дополнительно принят отдельным SQL/HTTP/restore-профилем, итоговый inventory проверен отдельно. Build и ESLint прошли. Порядок изменений и границы снимков зафиксированы в протоколе. +- [ ] CHR GRE whole-section restore пока не принят. [Последняя завершённая попытка](mcp-chr-gre-revision-1585ecd1-query-unknown.json) остановилась после первого PUT: native static provenance не подтверждён, результат unknown не повторялся. Отдельный новый человеческий план DELETE применён; восемь исходных fingerprints и полный proof зависимостей восстановлены. Тестовый GRE удалён, SSH-туннель закрыт. SA/IPv6 readiness/forwarding не подтверждены. +- [ ] Полный план остаётся открытым: 38 REST-адаптеров, Route AI для non-main/VRF, полная native/трёхузловая аварийная traffic-приёмка с независимыми путями, ECMP/NAT/RPF/hash и PSK/SA, реальный portal sender/внешние клиенты и финальная браузерная приёмка. Два исторических route hash `c74d0d3c` остаются недоказанными. + +План согласован 16 октября 2026 года; актуальный статус выполнения отмечен ниже. + +Реализация ведётся в ветке `codex/mcp-foundation`. Актуальный статус и команды проверки — в [MCP: состояние реализации](mcp.md). Этот документ сохраняет полный согласованный объём; наличие инструмента в реестре не заменяет приёмку соответствующего сценария. + +### Статус на 23 октября 2026 (UTC+7) + +- [x] Контракты, миграции, реестр инструментов, политики, ограничения области доступа и лимиты. +- [x] Streamable HTTP, stdio-мост, непрозрачные токены, OAuth/PKCE, отзыв и ротация доступа. +- [x] Планы, подтверждения, межпроцессные блокировки, аудит до записи, idempotency и проверка результата. +- [x] Отдельный web-раздел MCP: подключения, разрешения, защиты, обращения, планы, операции и настройки. +- [x] Логи: поиск, контекст, follow, summary, настройки и ограниченный экспорт; SQL-проверка миллиона событий. +- [x] Типизированные инструменты пользователей/привязок, сетевых объектов и основных сервисов; точное покрытие показывает актуальный каталог. +- [x] Web-редактор Ethernet/bridge/VLAN/адресов/VRF через ограниченное подключение и общий исполнитель планов. +- [x] Анализ Route AI, профили и контроллер с подтверждениями; подготовка Community/OSPF/ECMP/RPF/VRF и ограниченных измерений. +- [x] Базовая приёмка на двух CHR: топология, подтверждение, создание/изменение/удаление собственного bridge, idempotency и восстановление baseline. +- [x] Изолированные маршруты на обоих CHR: отдельная non-FIB таблица, создание/включение/изменение distance, обратный план, запрет удаления используемой таблицы и восстановление исходных таблиц/маршрутов. Это проверка конфигурации, без перенаправления пользовательского трафика. +- [x] Реальная двухузловая GRE/VRF-приёмка: собственные IPv4/IPv6 подсети, ping/traceroute в обе стороны, подтверждённый отказ при отключении тестового GRE, сохранение управления и восстановление отдельным обратным планом. Полная очистка и сравнение исходных fingerprints семи ресурсов на каждом CHR. +- [x] GRE/WireGuard/VXLAN в зависимостях адресных и интерфейсных планов; защиты родителей. Создание VRF перед существующими записями, проверка порядка после записи и конфликт при перестановке после подтверждения. Автоматические IPv6 link-local адреса отделены от изменяемой конфигурации. +- [x] Типизированные OSPF instances/areas/templates и ограниченные правила BFD; зависимости и защиты VRF/таблиц/областей/интерфейсов, запрет неявного каскадного удаления. OSPFv2/v3 оптимизируются раздельно; включённый BFD проверяется до/после записи и в обратных планах. +- [x] Реальная CHR-приёмка OSPFv2/v3 в тестовой GRE/VRF: четыре Full/BFD Up соседства, измерения, cost 30→10 для каждого семейства без изменения другого, отдельное восстановление cost и полный возврат baseline. +- [x] ECMP с разрешённым неактивным higher-distance кандидатом: до записи проверяется точный resolved next-hop, после — active обоих маршрутов. На CHR пройдены IPv4/IPv6 10/20→10/10→10/20 с контрольными routed loopback-пробами и очисткой. Два next-hop через один GRE не объявляются независимыми физическими путями. +- [x] Типизированные BGP instances/connections, зависимости VRF/таблиц и peer-защиты. На двух CHR пройдены отдельные IPv4/IPv6 сессии, активные префиксы обоих семейств через MCP observer, явный withdrawal/reannounce, остановка IPv4 peer с сохранением IPv6 и обратное восстановление. BGP и все 20 overlay fingerprints вернулись к исходным; exit 0, 20 октября 08:18:38 UTC. Двухузловая dual-AFI Community-приёмка принята позднее: [протокол](mcp-bgp-community-dual-afi-acceptance.json); полная forwarding/failover и независимая трёхузловая приёмка остаются открытыми. [CHR prefix-отчёт](mcp-chr-bgp-prefix-acceptance.json). +- [ ] Полная приёмка маршрутизации на CHR, включая аварии и восстановление. +- [x] Независимый management-канал с проверкой ID/отзывом, контрольные пробы, failover по разным подтверждённым отказам, checkpoints/обратные native-планы и заранее разрешённый recovery Community. Реальная трёхузловая аварийная приёмка остаётся отдельным пунктом. +- [x] Терминальный restore БД: загрузка человеком, SHA-256/шифрование, отдельное подтверждение, внешний результат и отзыв доступа; настоящий изолированный pg_dump/restore-тест. +- [x] Права каталога /users, реальные типы привязок, PATCH без сброса defaults; настройки мониторинга, каталоги Ping/bandwidth, NetFlow analytics и host reconcile. +- [x] URL-фильтры обращений, время обновления, обзор исходного состояния/шагов плана и общая аварийная остановка. +- [x] Подписанный webhook отзыва портала, replay/idempotency и внешний журнал отзыва, переживающий restore. Настройка отправителя в настоящем портале ещё требуется. +- [ ] Полное покрытие оставшихся DB/host-сервисов общей policy-границей и всеми согласованными инструментами. +- [x] Проверяемый реестр всех 262 REST-маршрутов: 161 сервисный сценарий с MCP, 62 исключения транспорта/авторизации/самоуправления, 39 явно незавершённых адаптеров. Новый endpoint без записи в реестре ломает проверку; незавершённые сценарии видны в UI. Общие GRE, OSPF/BFD и BGP-каталоги подключены с ограничением области до опроса и двумя уровнями пагинации. Legacy OSPF preview/apply/restore приняты SQL/CHR; серверная и пользовательская история трафика — SQL/REST parity и test:mcp. +- [x] Общие REST/MCP WAN runtime и BGP sessions/raw: scope/disabled до опроса, пагинация, известный ноль/unknown, mask native keys, отсутствие forwarding claim. На обоих CHR десять видов чтения, ambiguous WAN и пустой BGP подтверждены без изменения конфигурации. +- [x] Read-only анализ OSPF из общего с cost-планом расчёта: наблюдатель не создаёт план/изменения, видит рейтинг и актуальные замеры; maxLossPct=1% по умолчанию, свежий плохой/неподтверждённый результат блокирует fallback к старому хорошему. SQL-приёмка анализа и сравнения с планом пройдена. +- [x] SQL-приёмка привязки OSPF cost-плана к observation ID/fingerprint/validUntil перед guarded-записями: свежий плохой замер, изменение строки и истечение пригодности после approval блокируют применение. Прежнее ограничение запуска снято, изолированная PostgreSQL-проверка пройдена. +- [x] GeoIP: общий status и guarded-план настроек, стабильный fingerprint конфигурации, предупреждение о немедленном запуске обновления при refresh расписания, проверка сохранения и защиты REST/MCP. Принудительный update ещё ожидает отдельного адаптера. +- [x] EvoBGP: общий GET без записи и без ключа, guarded-план настроек, секрет владельца привязан к нормализованному URL, явное удаление/безопасная смена назначения, fingerprint ротации ключа, read-back и маскирование секретов в журналах. Проверка доступности подключена через общий ограниченный modules GET; сохранённый ключ не пересылается новому URL без отдельного secret reference. +- [x] Каталог EvoBGP: общие parser/formatter и уникальные server counts, проверка полной области до HTTP/SQL, null для неизвестных счётчиков, пагинация до 200 и версия каталога, 8 MiB/15 s, отмена fetch, запрет redirects и отражения ключа. Реальный BGP prefix exchange этим не подтверждается. +- [x] Проверка оставшегося бюджета учитывает всю стоимость пакета и резерв обратной операции уже в симуляции/подготовке; точная граница и расход после подтверждения проверены отдельно. +- [x] История конфигураций filters/recursive-routes/firewall/WireGuard/IPsec/GRE через общий REST-сервис: серверная область, отдельные права, до 50 записей метаданных, без payload/ключей и без автоматического восстановления ревизии. +- [x] Строгие каталоги рекурсивных маршрутов/шлюзов: общий REST/MCP parser, effective properties, unknown состояния шлюзов. REST сохраняет подтверждённое наблюдение; MCP observer не меняет кеш/ревизии. +- [x] Восстановление ревизии recursive-routes отдельным человеческим планом: scoped SQL/fingerprint, полный owned-набор до 50 строк, таблицы/интерфейсы/защиты, запрет используемых rec:ID в BGP-фильтрах, стоимость create+delete+metadata, read-back и явная пустая ревизия. Проверены дрейф, нехватка бюджета, чужие маршруты и partial. На двух CHR пройдены отключённые маршруты в собственной non-FIB таблице и полная очистка; переключение трафика этим тестом не принимается. +- [x] Бюджет пакетного service-step резервируется по рассчитанной стоимости, а доступ повторно проверяется перед каждой guarded записью внутри шага. SQL/RouterOS-имитатор подтвердил остановку после отзыва между PUT и DELETE; уже отправленная команда не отменяется и не повторяется. +- [x] Scoped-каталоги containers/certificates и история серверов: пагинация, unknown/partial/unsupported, часовой пояс сертификатов, скрытие всех env values и cmd. Определение IPv4 управляющего host явно не подтверждает адрес интерфейса. +- [x] Удаление контейнеров через общий REST-сервис только из свежего stopped-состояния, с проверкой конфигурации и интерфейса. Native delete alias использует ту же подготовку; start/stop/restart проверяют идентичность до/после команды и защиты родителя. Подмена ID, дрейф, отсутствие read-back и повторы проверены имитатором/SQL; live-удаление на CHR без установленного containers не объявляется принятым. +- [x] Read-only preflight ECMP IPv4/IPv6: конфигурационные признаки NAT/FastTrack/mangle/notrack/RPF/общего egress, hash-policy и connection tracking; нет утверждения о проверке пакетов или независимости путей. Оба CHR подтвердили все десять источников, конфигурационные fingerprints до/после совпали. Физическая и traffic-приёмка остаётся незавершённой. +- [x] Readiness выполняет фактический initialize/tools-list через публичный MCP URL официальным SDK: временный токен без прав на операции, срок до 20 секунд, очистка после успеха/ошибки, cooldown, дедлайн и запрет redirects. Это проверка транспорта; настройки и credentials конкретного внешнего AI-клиента отдельно не подтверждаются. +- [x] Общие REST/MCP каталоги активных интерфейсов Ping/speed-test, scoped SQL-история скорости по обоим участникам и человеческий план видимости Ping-пробы без изменения расписания. Unknown flags/адреса, чужие строки, защиты, дрейф и read-back проверены. +- [x] Ограниченный live-трафик: до трёх срезов общего с REST сервиса, полный набор интерфейсов/один интерфейс, повторная проверка credential/grant и источника перед native-запросами. SQL проверяет отмену, partial, exact zero и отзыв; на обоих CHR подтверждены интерфейсы проб и live выбранного интерфейса без изменения конфигурации. +- [x] Общая REST/MCP проверка доступности EvoBGP: фиксированный modules GET, deadline/size/redirect, только сохранённый ключ либо secret reference владельца для назначения, полный scope до HTTP, отсутствие сохранения/отражения ключа и явное отсутствие подтверждения routing. +- [x] Общие OSPF/BFD state-read сервисы для legacy REST и MCP: nullable observed-проекция, areas/instance по настоящим ссылкам, native ID до IPv4/IPv6 namespace, отдельное чтение BFD и пагинация по серверу/коллекциям. Неподтверждённые cost/version/счётчики/state не выдумываются; scope/disabled/failure проверены SQL и моделями. +- [x] Общий firewall-каталог: десять scoped источников, отдельные matcher-поля, известный ноль и unknown flags/counters, paging/failures и запрет сохранения предположительного observed snapshot. Все конфигурационные REST-сценарии Firewall подключены к сервисным MCP-адаптерам; packet matching принимается отдельно. На двух CHR пройдены 16 видов чтения с 15 неизменными fingerprints; OSPF/BFD/firewall в этом запуске пустые, непустые состояния проверены SQL/имитатором. +- [x] Общий REST/MCP move правила firewall: sealed human plan, полная конфигурация/порядок таблицы без runtime counters, подтверждённо статические source/destination, защиты всей затронутой таблицы/сервера, read-back и отсутствие retry. На двух CHR выполнены три отключённых правила в отдельной цепочке, перемещение/явный возврат, idempotency и очистка с восстановлением восьми firewall fingerprints. Сопоставление реальных пакетов этим тестом не принимается. +- [x] Общие VPN-каталоги WG/IPsec/VXLAN с серверной областью до HTTP и вложенной пагинацией interfaces/peers/allowedIps/VTEP/certificates. Native identity/parents, nullable flags/counters, unknown/ambiguous IPsec sessions, маскирование key material и отражённых секретов проверены SQL/моделями. Read-only на обоих CHR: 20 видов чтения, 15 неизменных fingerprints. Непустой VXLAN выявил отсутствующий l2mtu — поддержан null в API/MCP/web-схеме без выдуманного MTU или экспорта. +- [x] На обоих CHR пройдена непустая конфигурационная приёмка GRE/WG/VXLAN/IPsec: собственные отключённые объекты, приватный WG-ключ исключён из MCP/аудита, null L2 MTU и destination port подтверждены нативным ответом, fixtures удалены и шесть fingerprints восстановлены. Peers/SA и передача VPN-трафика этим тестом не принимаются. +- [x] Общий GRE reader REST/MCP: три scoped источника, native identity/parent и family адресов, страницы, nullable flags/MTU/keepalive, непроверенные comment metadata и скрытие PSK/отражений. SQL подтверждает права, отказ каждого источника, отсутствие записи истории и секретов в аудите. На двух CHR прочитаны собственные отключённые GRE, MTU/keepalive совпали; после удаления GRE/WG/VXLAN/IPsec fixtures восстановлены шесть fingerprints. Read-only приёмка расширена до 20 видов чтения без мутаций. +- [x] Четыре сервисных Firewall rule CRUD-плана и общие REST-обработчики: static native identity, sealed full-table/order proof без counters, все защиты сервера, locks/source refresh, точный read-back и отдельный статус ревизии. SQL/REST/модели проверяют TLS/L7, дрейф, блокировки, partial/no replay. На двух CHR пройдены IPv4/IPv6 create/edit/toggle/delete в собственной непривязанной chain; восемь fingerprints восстановлены, packet matching отдельно не проверялся. +- [x] Четыре общих REST/MCP address-list CRUD-плана: native ID/known origin, серверные защиты зависимых правил, locks/source refresh/read-back/no retry и канонизация IPv4/IPv6/range. Отдельное expiry window и срок плана сохраняют естественный countdown и блокируют продление вне точности источника. SQL/REST проверяют finite drift/partial/idempotency. На обоих CHR пройдены v4/v6 permanent CRUD и timed delete, десять fingerprints восстановлены. Dynamic entries не объявляются сохранёнными в постоянной ревизии; DNS forwarding/packet matching отдельно не проверялись. +- [x] Общий строгий REST/MCP Firewall revision restore: scoped hash/schema, полный static порядок/дубликаты, расширенные native matcher-снимки, консервативные защиты/budget/locks и read-back каждой стадии. Все новые записи сначала disabled; старые удаляются только после подготовки, затем включаются нужные. Dynamic rows сохраняются перед новым static-набором, RAM entries не восстанавливаются. SQL/REST проверяют partial/unknown/no retry, отзыв, activation и ошибочные ревизии. На двух CHR пройдены шесть отключённых v4/v6 правил, два списка, пустой restore и десять восстановленных fingerprints. Legacy snapshots и отсутствие packet/production activation-приёмки обозначены явно. +- [ ] Настроенная и принятая доставка отзыва из настоящего auth-portal; приёмка внешних AI-клиентов с OAuth/Bearer. +- [x] Полная backend-регрессия по всем профилям и SQL/live logs зелёная; девять web E2E пройдены. Отдельные старые SQL-ветки с собственным MMAPP_OWN_TEST_DB не объявляются выполненными. + +- [x] NetFlow exporters/clients/monthly через общие REST/MCP readers: серверная область и временный grant до SQL/агрегации, исключение пользователей со скрытыми привязками, страницы и пределы источников. История отключённых серверов сохранена, счётчики проверяются на переполнение; named ingress, peer/ifIndex и кольцо flow отмечены по фактическому происхождению без заявления о wire rate/полноте трафика. Модели, SQL/REST, полный backend-прогон и настоящий pg_dump/restore пройдены. + +- [x] Общие legacy REST/MCP каталоги контейнеров: один collector для общего и серверного списка, стабильный native-ID порядок страниц, exhausted/truncated страницы помечены неполными. HTTP/SQL подтверждает одинаковые объекты и статусы, скрытие env/cmd в MCP, сохранение legacy 502 при partial и явный unsupported в MCP. Реальный loopback-профиль и проверки удаления/состояний контейнеров прошли; запуск контейнеров на CHR без пакета не заявляется. + +- [x] Общий источник legacy REST/MCP сертификатов с schema/clock offset: GET и refresh используют те же ограниченные чтения, malformed ответ даёт failures, ключи не выдаются. MCP сохраняет unknown для неподтверждённых сроков/статусов и стабильную пагинацию по ID. SQL/REST, профиль сертификатов и настоящий pg_dump/restore прошли; legacy DTO сохранён отдельно от более строгого observed DTO. + +- [x] Сервисный каталог BGP-in filters: общие REST/MCP live/cache источники и schema/native-ID проверка, scoped cache fallback, nullable native flags и относительный порядок. MCP observer не заменяет cache и не создаёт ревизию; parser Community/gateway, recursive refs и эффекты script не объявляются подтверждённой политикой. SQL/REST, модели, config-sync/live-maps и настоящий pg_dump/restore прошли. GET с некорректным serverId отклоняется до DB/опроса; отключённый сервер не опрашивается. Повторная read-only приёмка двух CHR: 21 вид чтения, 15 совпавших fingerprints; новый BGP-in live-каталог пустой, непустая семантика отдельно не заявляется. + +- [x] Три GRE business CRUD-плана и общий REST-исполнитель: native ID/полный opaque proof, IPv4/IPv6 адреса, сохранение вторичных адресов/таймеров/PSK, scoped secret reference и явная очистка ключа, проверки серверных защит и зарегистрированных зависимостей. Disabled staging, бюджет всех команд, locks/source refresh, проверка каждой команды, partial/unknown/no replay и отзыв между шагами подтверждены SQL/HTTP. На двух CHR пройдены собственные отключённые GRE, v4→v6, удаление и восемь восстановленных fingerprints. invalid-адрес на выключенном GRE не объявляется готовым к передаче трафика; конфигурация и readiness разделены. +- [ ] Дополнительная приёмка GRE business: активный туннель с передачей пакетов и непустой PSK/SA; SQL-проверка ключей не заменяет эту сетевую приёмку. +- [x] Native GRE update/delete используют общий видимый proof и проверки DB-привязок/filters, адресов, интерфейсных/маршрутных/протокольных/Firewall ссылок. Rename/delete не удаляют адреса каскадно; scalar update сохраняет прежнюю грань одного интерфейса. Прямой исполнитель GRE delete/rename, включая inverse/legacy ROS steps, проходит тот же guard. SQL/модели проверили поздние связи, opaque drift, порядок VRF/rules/Firewall, counters, пустой comment и отсутствие повторов. На двух CHR выполнены native rename/comment-clear/delete и восстановлены восемь fingerprints. +- [x] Legacy recursive GET подключены общими readers: устойчивые DB ID и ссылки rec:, строгие нули/nullable свойства, scoped live/stale fallback, up/down/unknown без forwarding claims. UPDATE/INSERT вместо замены всех строк; неоднозначность, удаление/смена префикса используемого маршрута блокируются до native-команд. Общее владение блокировками с BGP-фильтрами; observer не меняет кеш/ревизии. Модели, HTTP, PostgreSQL и настоящий pg_dump/restore прошли. На двух CHR подтверждены ID после native recreation, повторные чтения с rec-binding, отсутствие observer writes и полная очистка (19 октября 11:56:57 UTC). +- [x] Полный apply/copy recursive-набор через mm_recursive_routes_replace_plan: до 50 строк, явный пустой набор, человеческое подтверждение, create+delete+metadata budget, sealed native/DB/BGP proof, стабильные ссылки и отсутствие повторов. Общий REST/MCP исполнитель проверяет каждый PUT до удаления старого набора, исходный target перед DELETE и отсутствие ID после него; metadata/native read-back обязателен. Проверены ignored writes, ID reuse, late drift, partial/unknown и отзыв между командами. На обоих CHR редактирование отключённого non-FIB маршрута с rec-binding и пустой набор прошли, baseline восстановлен (19 октября 12:07:12 UTC). Пакет не принимается как проверка forwarding/failover. +- [x] Мониторинг реальной REST backup-очереди через mm_backup_job_get: задача доступна только целиком в разрешённой области, проверены серверы/прогресс/страницы результатов. Заметки, имена файлов/устройств, S3 keys и тексты ошибок не отражаются в MCP-ответе/аудите. Сохранены известный ноль и upload-error flag; неизвестное состояние файла не подменяется available. Общий реестр очищается при app close и DB restore. Модели, backup HTTP-профиль, PostgreSQL/MCP и настоящий pg_dump/restore прошли. Постоянная история очереди этим адаптером не реализуется. +- [x] После recursive/backup блока повторён весь backend test:all через test:mcp:pg:all --restore в отдельной временной БД, включая SQL logs и настоящий pg_dump/pg_restore; итог Regression failed profiles: none, процесс завершился с кодом 0. Отдельно прошли backend build и ESLint. Последующие изменения требуют своих проверок. +- [x] Сохранённый Internet path подключён общим latest-reader с проекцией в SQL до разбора скрытых JSON-записей, проверкой привязок, маскированием секретных полей и вложенными страницами. Отдельно отмечены legacy interpretation, возраст/stale/clock skew и отсутствующие серверы; live/current routing/forwarding не подтверждаются. Проверены пустая история, повреждённые/непривязанные записи, скрытые mixed speed probes, REST timestamp parity и отсутствие запросов RouterOS. Для этого следующего блока выполнены модели, SQL/MCP, build и ESLint; полная регрессия выше относится к предшествующему recursive/backup состоянию. +- [x] PUT backup storage подключён через общий REST-сервис: глобальный человеческий план, полный каталог/защиты, owner/destination-bound secretRef, запрет переноса сохранённого ключа на новое usable-назначение без ссылки. Учтены существующие S3-архивы, late busy/catalog/key/ref drift, конфигурация целиком и сохранение непрошенных полей/ключа. Очистка неиспользуемого bucket и повторное включение с подходящей ссылкой проверены. Отпечаток секрета вычисляется с закрытым серверным ключом и разделением по назначению/установке; plaintext не попадает в план/аудит. Модели, backups-профиль, SQL/MCP, build, ESLint и настоящий pg_dump/pg_restore прошли; последний guard пустого bucket дополнительно проверен SQL. Доступность S3 этим действием не заявляется, для неё отдельный test-план. + +- [x] Legacy OSPF preview подключён общим REST/MCP reader: peer scope и probe SQL ограничиваются до чтения, latest aggregate traffic требует подтверждённой дельты; отдельные WG peers и первый/ошибочный sample не выдаются за скорость интерфейса. MCP разделяет modeled/observed, ограничивает peers/страницы, проверяет доступ и источник перед GET/POST и после чтения. Отмена GET закрывает HTTP; ошибка одного источника отменяет остальные запросы группы. SQL, OSPF/transport модели, build и ESLint прошли. На обоих CHR — 22 read-сценария, 15 неизменных fingerprints, пустой набор OSPF templates; мутаций и планов записи нет (19 октября 13:37:07 UTC). Непустой рейтинг проверен SQL, изменение cost этим preview не принимается. +- [x] После изменения отображения coverage повторены test:mcp и связанный test:live-maps: оба процесса завершились с кодом 0. Coverage подтвердил 292 инструмента, 137 mediated / 62 excluded / 63 pending. После восстановления доступности аккаунта оба запуска прошли штатную проверку разрешений; обхода прежнего отказа review не было. +- [x] Полная регрессия после Internet path/storage/OSPF: исправлены зависимые от timezone date-only границы разделов PostgreSQL, добавлен SQL-тест UTC+7/UTC/DST, суток/недель/високосного месяца и DATE keys. Build/ESLint, test:mcp:pg:all --restore (26 профилей и дополнительный SQL logs, настоящий pg_dump/restore) и 9 браузерных MCP-сценариев прошли, exit 0. На Debian-стенде read-only аудит подтвердил UTC-границы всех 85 timestamp-разделов; SSH закрыт. Существующие разделы не перестраивались. Подробности и границы доказательств: [отчёт регрессии](mcp-regression-acceptance.json), [аудит разделов](mcp-postgres-partition-audit.json). + +- [x] Три сервиса истории трафика серверов подключены через общие REST/MCP readers: список enabled-серверов, отдельный сервер/интерфейс и интерфейсы за 24 часа. Scope/grants применяются до SQL, известный ноль отделён от unknown, native status известен только при statusKnown, error/stale/pending/disabled не становятся текущей скоростью. Предел 20000 samples на историю и 100000 на интерфейсный каталог отклоняет расчёт целиком; страницы не скрывают неполноту. Late source drift, отзыв права владельца и pause во время SELECT блокируют выдачу. PostgreSQL/MCP, реальный pg_dump/restore, профиль traffic-rate с SQL logs, модели/coverage, build и ESLint прошли. Native-запросов, observer-планов и изменений CHR нет. Исправлен OSPF SQL fixture: traffic_samples использует недельные разделы, как приложение. Пользовательские адаптеры подключены следующим отдельным блоком ниже. + +- [x] История пользователей/привязок подключена тремя общими REST/MCP адаптерами. SQL eligibility исключает mixed users с привязкой вне grant до JSON/метрик; выбранный поднабор серверов явно помечает subtotal и completeSourceSet. Пагинация серверов, пользователей и привязок; максимум 200 выбранных привязок пользователя/20000 samples сервера. Peer-specific counters доступны без вывода ключа; unknown/disabled/account состояния разделены. Unbound users требуют allowGlobal и весь каталог серверов. Binding drift после чтения блокирует результат; observer не создаёт планы и не опрашивает устройства. MCP/SQL с настоящим pg_dump/restore, модели/coverage (298 tools, 143/62/57), build и ESLint прошли. В общем REST/MCP reader исправлена верхняя граница окна: будущий sample больше не искажает текущую скорость; финальный SQL проверил это в обоих API. +- [x] После восстановления лимита аккаунта штатный review разрешил повтор traffic-rate профиля и SQL logs с последней верхней границей окна: MCP SQL, настоящий pg_dump/restore и оба профиля прошли, exit 0, Regression failed profiles: none. Наблюдавшаяся гонка teardown traffic-status SQL исправлена ожиданием scheduler jobs/journal до закрытия pool; в заключительном прогоне сообщения о закрытом pool нет. Build и ESLint прошли. Прежний отказ review не обходился. [Отчёт пользовательского блока](mcp-user-traffic-history-acceptance.json). + +- [x] BGP redistribution через mm_bgp_export_plan: connected/static/disabled одного статического peer, три совместных права, обязательное человеческое подтверждение, явные instance/VRF/table, запечатанные источники/фильтры/шаблоны. Смена credentials после approval отклоняется до сетевого запроса; nil не подменяется false, неоднозначное наследование и возврат inherited export при очистке запрещены. SQL/restore, build, ESLint и модели прошли; native prefix exchange принимается отдельным CHR-отчётом. +- [x] Полная backend-регрессия после серверной/пользовательской истории трафика и BGP export: 26 профилей test:all и SQL logs, MCP SQL и настоящий pg_dump/restore, Regression failed profiles: none, exit 0. Первый запуск без client tools в PATH остановился; успешный повтор использовал существующий локальный комплект. Последний static-query fallback дополнительно прошёл SQL/restore, build и ESLint. + +- [x] Read-only mm_bgp_prefixes_get: совместные BGP/network права, одна таблица и семейство, native origin/prefix checks, 10000 строк до пагинации, nullable active/distance, table/source/access fences и отмена. Observer не создаёт планов, не отражает native secrets и не заявляет attribution к peer/advertised attributes/forwarding. Модели/coverage (300 tools, 143/62/57), build, ESLint и SQL/настоящий pg_dump/restore прошли, включая доказанный late owner/pause race. [Отчёт observer](mcp-bgp-prefix-observer-acceptance.json). +- [x] Двухузловая BGP-передача контрольных пакетов через MCP path verifier: 11 IPv4/IPv6 проверок 3/3 с traceroute до loopback и 7 подтверждённых отказов 3/0 после withdrawal/peer stop/удаления connections. Повторный анонс и inverse восстановили путь, IPv6 сохранился при отключении IPv4. Connected GRE используется для ответа; пользовательский трафик и независимый транзитный failover не заявляются. 20 fingerprints восстановлены, SSH закрыт; CHR exit 0 20 октября 08:28:01 UTC. Build/ESLint нового fixture прошли. [Отчёт контрольных проб](mcp-chr-bgp-forward-acceptance.json). + +Community допускает ограниченную автономность только при явном допуске, свежих метриках/проверке пути, независимом управлении и действующих защитах. Остальные нативные изменения требуют человека. В коде 328 инструментов; REST 168 mediated / 62 excluded / 32 pending. Полное покрытие сервисных REST-сценариев, завершение всего native harness, независимые пути/hash-policy/NAT/RPF и трёхузловая аварийная приёмка ещё не завершены. Последняя полная backend-регрессия после общего GRE revision restore прошла: 27 профилей, MCP SQL/HTTP, настоящий pg_dump/restore и SQL logs; build и lint успешны. Предыдущие отдельные WireGuard/UTF-8 сценарии сохранены в собственных отчётах. 9 браузерных MCP-сценариев выше относятся к состоянию перед историей трафика. diff --git a/docs/mcp-linux-critical-acceptance-20261103.json b/docs/mcp-linux-critical-acceptance-20261103.json new file mode 100644 index 00000000..086d36c4 --- /dev/null +++ b/docs/mcp-linux-critical-acceptance-20261103.json @@ -0,0 +1,31 @@ +{ + "date": "2026-11-03", + "status": "audit-seven-accepted-maintenance-loopback-refused-tls-not-run", + "archiveSha256": "f4153fdc55db542f7da6c4d6b589ae10f3c6c48c404442239130bb9bf0787007", + "summary": "tmp/mcp-linux-critical-capsule-20261103/private-linux-run-2c37b62e7ebfbcafd63829f7f53182d42d421ab02dd79a57/summary.json", + "summarySha256": "de09d3e28dfe0c14f9d1197ee973e6f5d4382403f557793f02b05bc1d059393e", + "audit": { + "accepted": true, + "genuineSqlHttpCasesPassed": 7, + "sourceSnapshot": "before-optional-control-schema-fix" + }, + "maintenance": { + "loopbackAccepted": false, + "tlsExecuted": false, + "failureFrame": "writer-profile/maintenance-resolution.mjs:128", + "maskedOriginalFailure": true, + "sourceDefectsFound": [ + "Unconditionally required optional mmapp_control namespace absent from ordinary44 migrations", + "Primary observation error replaced by cleanup assertion despite confirmed retirement" + ], + "fixSourceApplied": true, + "targetedTestsPassed": 17, + "newLinuxAcceptanceRequired": true + }, + "originalServicesUnchanged": true, + "ownResourcesRemoved": true, + "remotePacketRemoved": true, + "observedOom": false, + "wholeInstallation2GbAccepted": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-linux-maintenance-resolution-acceptance-20261103.json b/docs/mcp-linux-maintenance-resolution-acceptance-20261103.json new file mode 100644 index 00000000..5f07a81c --- /dev/null +++ b/docs/mcp-linux-maintenance-resolution-acceptance-20261103.json @@ -0,0 +1,65 @@ +{ + "date": "2026-11-03", + "status": "genuine-linux-loopback-and-tls-accepted", + "archiveSha256": "da38596744f88f4a79b2d8524828b907cd75da4e252181518e5e42eccdfbcdf0", + "summary": "tmp/mcp-linux-maintenance-v2-20261103/private-linux-run-1e241fd4bacaf2a3335389fff03fd7cf3f37643d2652154b/summary.json", + "summarySha256": "c8a73b1b09de7590873f5bdfa6f8deb99d60e5f71897d579db602abc34393011", + "cases": [ + { + "case": "maintenance-loopback", + "passed": true, + "genuinePostgres": true, + "nativePosixProtection": true, + "cases": [ + "current44", + "unknown-before-sql", + "native-inspect", + "native-resolve", + "applied-audit-only", + "native-permission-refusal", + "inspection-reconciliation", + "new-unknown-before-sql" + ], + "report": "tmp/mcp-linux-maintenance-v2-20261103/private-linux-run-1e241fd4bacaf2a3335389fff03fd7cf3f37643d2652154b/run-maintenance-loopback-e5cf38096078701fecc2233db5a67da23bde308191c67887/work/maintenance-result.json", + "reportSha256": "936ff1178e2a2cd5a14882766b28df0d708a91b220782d7f211bf2a1f8785675" + }, + { + "case": "maintenance-tls", + "passed": true, + "genuinePostgres": true, + "nativePosixProtection": true, + "cases": [ + "current44", + "unknown-before-sql", + "native-inspect", + "native-resolve", + "applied-audit-only", + "native-permission-refusal", + "inspection-reconciliation", + "new-unknown-before-sql" + ], + "report": "tmp/mcp-linux-maintenance-v2-20261103/private-linux-run-1e241fd4bacaf2a3335389fff03fd7cf3f37643d2652154b/run-maintenance-tls-508773039b44baa36c448d2425b9a8d4703546c46c7ca9fb/work/maintenance-result.json", + "reportSha256": "95967fc7d67f6e46b12dbf888b0fc62f13d3810df11ca104e54cdb8c453a4470" + } + ], + "totalCasesPassed": 16, + "currentMigrations": 44, + "nodeMajor": 22, + "capsMiB": { + "node": 320, + "postgres": 192, + "total": 512 + }, + "originalServicesUnchanged": true, + "ownResourcesRemoved": true, + "remotePacketRemoved": true, + "oomObserved": false, + "destructiveRestoreDispatched": false, + "syntheticApprovalOnly": true, + "historicalUnknownOutcomesChanged": false, + "archiveDataEquivalenceProved": false, + "componentSnapshotBeforeLaterLiveAdmissionPatch": true, + "wholeInstallation2GbAccepted": false, + "fullDbDirectionComplete": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-linux-original-effects-acceptance-20261103.json b/docs/mcp-linux-original-effects-acceptance-20261103.json new file mode 100644 index 00000000..eb45c500 --- /dev/null +++ b/docs/mcp-linux-original-effects-acceptance-20261103.json @@ -0,0 +1,103 @@ +{ + "date": "2026-11-03", + "status": "genuine-linux-three-original-effect-sql-runtime-cases-accepted-component", + "archiveSha256": "fe521d7a531115f373e33928501aa4a11ae851023867dcb6bddbd885e7135072", + "manifestSha256": "e711d8d70395ab3c2e2b359bc7492c60e87115a9dcd19881d72813573e7fb82e", + "componentSummary": "tmp\\mcp-linux-routing-effects-v5-20261103\\private-linux-run-773fa7af00073e4ce95a5b48ecdb97a6eaf5f1bac53970e6\\run-routing-original-effects-native-restore-5fa22ea9d2a08889f006eda8c3ce65f1c9f0aa88b5d5018a\\work\\original-effects-sql-runtime-summary.json", + "componentSummarySha256": "bbd681475a648c3a61799d0504e79765b03b88859ef666935aad98f9b5f735e9", + "cases": [ + { + "case": "transition", + "manualState": "applied", + "configurationCheckpoint": true, + "actualPrivateExecutionOrigin": true, + "effectStatus": "observed-expected", + "observedFibTransition": true, + "nativeRibBefore": 1, + "nativeRibAfter": 2, + "sharedBudget": { + "deadline": 1793721833129, + "calls": 168, + "bytes": 20246, + "rows": 207 + }, + "effectBaselineAdvanceAccepted": true, + "effectGateNativeReads": "fresh-native-checked", + "actualTickPausedByExistingScopedGuard": true, + "tickEffectPauseAccepted": false, + "publicManualRevision2EffectOwnerAccepted": false + }, + { + "case": "no-op", + "manualState": "applied", + "configurationCheckpoint": true, + "actualPrivateExecutionOrigin": true, + "effectStatus": "unknown", + "observedFibTransition": false, + "nativeRibBefore": 1, + "nativeRibAfter": 2, + "sharedBudget": { + "deadline": 1793721945901, + "calls": 168, + "bytes": 20246, + "rows": 207 + }, + "effectBaselineAdvanceAccepted": false, + "effectGateNativeReads": 0, + "actualTickPausedByExistingScopedGuard": true, + "tickEffectPauseAccepted": false, + "publicManualRevision2EffectOwnerAccepted": false + }, + { + "case": "ambiguous", + "manualState": "applied", + "configurationCheckpoint": true, + "actualPrivateExecutionOrigin": true, + "effectStatus": "unknown", + "observedFibTransition": false, + "nativeRibBefore": 0, + "nativeRibAfter": 0, + "sharedBudget": null, + "effectBaselineAdvanceAccepted": false, + "effectGateNativeReads": 0, + "actualTickPausedByExistingScopedGuard": true, + "tickEffectPauseAccepted": false, + "publicManualRevision2EffectOwnerAccepted": false + } + ], + "realMcpDispatcher": true, + "realSql": true, + "routerOsReplies": "declared synthetic localhost HTTP", + "approval": "synthetic owner through actual approvePlan; not Portal/browser human acceptance", + "genuineRunningOrigin": true, + "actualConfigurationCheckpoint": true, + "signedEffectTamperRejected": true, + "completedPlanSameOperationNoNativeReplay": true, + "productionSourcesStillCurrent": true, + "laterTestOnlyChange": [ + "backend/src/modules/mcp/routing-network-native.sql.test.ts" + ], + "budgetScope": "effect owner only; old manual preflight/readback transports are outside this168-call owner. Not a whole-operation/all-writer budget acceptance.", + "nativeOwnerCallsPerPreparedCase": 168, + "observedRuntimeHttpRequests": [ + 495, + 495, + 354 + ], + "publicManualRevision2OwnerEnabled": false, + "scopedSchedulerEnabled": false, + "tickPause": "existing scoped activation guard, not effect-triggered controller pause", + "autoRecoveryEnabled": false, + "nativeChrAccepted": false, + "forwardingVerified": false, + "fullRoutingComplete": false, + "fullPlanComplete": false, + "routing18Accepted": false, + "terminalRestoreAccepted": false, + "overallCapsulePassed": false, + "followingFailure": "Readonly test incorrectly asserted global checkpoint count0 after three genuine checkpoints from previous phase. Test changed to exact starting inventory preservation.", + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true, + "wholeInstallation2GbAccepted": false +} diff --git a/docs/mcp-linux-routing-effects-attempts-20261103.json b/docs/mcp-linux-routing-effects-attempts-20261103.json new file mode 100644 index 00000000..547c6290 --- /dev/null +++ b/docs/mcp-linux-routing-effects-attempts-20261103.json @@ -0,0 +1,141 @@ +{ + "date": "2026-11-03", + "status": "v6-effects3-and-routing18-accepted-terminal-no-replay-test-raced-retirement", + "attemptsUsed": 3, + "maxRunsApproved": 3, + "archiveSha256": "862ea6bcb502955016ce1dd6a1217e66e77ee2e74339858b496d41a70b147f65", + "failure": "inet_server_addr()::text produced127.0.0.1/32; fixed wrapper uses host(inet_server_addr()) and still requires exact127.0.0.1", + "effectScenariosRun": 0, + "routing18Run": false, + "terminalRestoreRun": false, + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true, + "oomObserved": false, + "wholeInstallation2GbAccepted": false, + "secondAttempt": { + "archiveSha256": "7b78068de5077df69eb11a9222a75d067cccae858a9a672c631af3171b9a3b93", + "status": "genuine-runtime-refused-expected-two-post-rib-reads-observed-one", + "nativeRibBefore": 1, + "nativeRibAfter": 1, + "ownerObservedSeconds": 131.635, + "routeEffectsAccepted": false, + "routing18Run": false, + "terminalRestoreRun": false, + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true, + "oomObserved": false + }, + "remainingAuthorizedRuns": 0, + "thirdAttempt": { + "archiveSha256": "df58289757e31a988e9ec7d161801abd1a5b8ed6cd228bf4b2935577e5fa436b", + "manifestSha256": "30fe159c05707693f9c51b5dfae4a3b8c32ff279b9fa05697995034befd349c3", + "diagnostic": { + "case": "transition", + "state": "applied", + "status": "unknown", + "reason": "effect-publication-revalidation-unavailable", + "rounds": 0, + "budget": { + "deadline": 1793720690043, + "calls": 186, + "bytes": 21937, + "rows": 227 + }, + "ribBefore": 1, + "ribAfter": 2, + "runtimeNativeRequests": 540, + "lastNativePaths": [ + "/routing/filter/select-rule", + "/routing/bgp/vpn", + "/routing/rule", + "/ip/firewall/mangle", + "/ipv6/firewall/mangle", + "/ip/route", + "/interface", + "/ip/address" + ] + }, + "summarySha256": "f9517d7821e1767bccbf92103970d27a7446de29d599cfd02a06210d6cdb70b2", + "status": "configuration-applied-two-post-rib-reads-effect-publication-unknown", + "completeTwoRoundCheckAccepted": false, + "effectScenariosAccepted": 0, + "routing18Run": false, + "terminalRestoreRun": false, + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true, + "oomObserved": false + }, + "currentGrant": { + "startingArchiveSha256": "22d498d1f3a29facd609c1ac98fe70e0efa0624fa27b3af6b5ca49ee000b5bc4", + "maxRuns": 3, + "attemptsUsed": 3, + "remainingRuns": 0 + }, + "v4Attempt": { + "diagnostic": { + "case": "transition", + "state": "applied", + "status": "observed-expected", + "reason": null, + "rounds": 2, + "budget": { + "deadline": 1793721369665, + "calls": 168, + "bytes": 20246, + "rows": 207 + }, + "ribBefore": 1, + "ribAfter": 2, + "runtimeNativeRequests": 495, + "lastNativePaths": [ + "/routing/filter/select-rule", + "/routing/bgp/vpn", + "/routing/rule", + "/ip/firewall/mangle", + "/ipv6/firewall/mangle", + "/ip/route", + "/interface", + "/ip/address" + ] + }, + "status": "positive-transition-and-checkpoints-passed-test-wrongly-expected-idempotency-rejection", + "productionIdempotencyChangeRequired": false, + "idempotencyAssertionCorrected": true, + "effectPhaseComplete": false, + "routing18Run": false, + "terminalRestoreRun": false, + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true, + "oomObserved": false + }, + "v5Attempt": { + "archiveSha256": "fe521d7a531115f373e33928501aa4a11ae851023867dcb6bddbd885e7135072", + "originalEffectsComponentAccepted": true, + "componentReport": "docs/mcp-linux-original-effects-acceptance-20261103.json", + "routing18Accepted": false, + "terminalRestoreRun": false, + "failure": "readonly global checkpoint inventory expected0 but actual3 genuine prior checkpoints", + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true, + "oomObserved": false + }, + "v6Attempt": { + "archiveSha256": "12ea0c1013c64f9de1703e0d9f0b3c5b924a4b1c25df687548b722c898749f68", + "effects3Accepted": true, + "routing18Accepted": true, + "nativeReadOnlyRequests": 151, + "checkpointsPreserved": 3, + "terminalAppliedRestoreReached": true, + "terminalReplayAndTamperComplete": false, + "failure": "Immediate stale callback raced still-live exclusive after applied receipt publication; production409 was correct", + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true, + "oomObserved": false + } +} diff --git a/docs/mcp-linux-routing-effects-preparation-20261103.json b/docs/mcp-linux-routing-effects-preparation-20261103.json new file mode 100644 index 00000000..d51703ab --- /dev/null +++ b/docs/mcp-linux-routing-effects-preparation-20261103.json @@ -0,0 +1,34 @@ +{ + "date": "2026-11-03", + "status": "v5-reviewed-second-new-authorized-run-in-progress", + "archive": "tmp\\mcp-linux-routing-effects-v5-20261103\\private-reviewed.tar.gz", + "archiveSha256": "fe521d7a531115f373e33928501aa4a11ae851023867dcb6bddbd885e7135072", + "manifestSha256": "e711d8d70395ab3c2e2b359bc7492c60e87115a9dcd19881d72813573e7fb82e", + "archiveReadbackFiles": 2841, + "staticGraphModules": 607, + "nonliteralDynamicImports": 0, + "pureBoundaryCasesPassed": 8, + "pureOwnerCasesPassed": 5, + "exactMigrations": 44, + "phases": [ + "original-effects-three-synthetic-http-genuine-sql-runtime", + "routing-existing18-sql-http", + "genuine-encrypted-terminal-restore-tamper" + ], + "memoryMiB": { + "node": 320, + "postgres": 192, + "total": 512 + }, + "sourceMountedReadOnly": true, + "existingImageSupplies": "Node22, production dependencies and PostgreSQL18 client tools; application JS is current separately pinned build", + "originalServiceStopsAllowed": false, + "workingDatabaseChangesAllowed": false, + "chrChangesAllowed": false, + "nativeForwardingAccepted": false, + "fullRoutingAccepted": false, + "wholeInstallation2GbAccepted": false, + "attemptsReport": "docs/mcp-linux-routing-effects-attempts-20261103.json", + "sourceCorrectionReport": "docs/mcp-routing-forward-context-source-applied-20261103.json", + "testOnlyCorrection": "Existing applyPlan returns the original operation for a completed plan: assert same id/applied state, exactly one SQL operation and zero new native requests/PATCH. Runtime code unchanged." +} diff --git a/docs/mcp-linux-routing-native18-acceptance-20261103.json b/docs/mcp-linux-routing-native18-acceptance-20261103.json new file mode 100644 index 00000000..b446cc43 --- /dev/null +++ b/docs/mcp-linux-routing-native18-acceptance-20261103.json @@ -0,0 +1,50 @@ +{ + "date": "2026-11-03", + "status": "genuine-linux-routing18-sql-http-component-accepted", + "archiveSha256": "12ea0c1013c64f9de1703e0d9f0b3c5b924a4b1c25df687548b722c898749f68", + "manifestSha256": "a751202d46aaf6537b2149ac637886025553beb29a4aa7ea2359bb4cb6f6844a", + "componentSummary": "tmp\\mcp-linux-routing-effects-v6-20261103\\private-linux-run-ec3d79a5b204d7129a36666f4c14e8acc8ca1b7e691f0901\\run-routing-original-effects-native-restore-bbaf8b71c8d56786930066cacd37c5e7e01d7489d3347173\\work\\routing-sql-http-summary.json", + "summarySha256": "1dd62b5fb161b03111270d5c7309739e9615fc38291e573634724b34b6661e0c", + "cases": [ + "actual fixed read-only reader preserves one captured authority/source and cumulative wire budget across three stages", + "actual terminal zero-received ping skips traceroute without inventing log coverage", + "reordered stage permanently closes session without native continuation", + "late profile after actual GET stops subsequent native requests", + "late endpoint-aba after actual GET stops subsequent native requests", + "late cancel after actual GET stops subsequent native requests", + "tampered source request and extended original deadline refuse before HTTP", + "actual redirect response stops without retry or redirect", + "actual auth response stops without retry or redirect", + "actual malformed response stops without retry or redirect", + "actual oversized response stops without retry or redirect", + "actual rows response stops without retry or redirect", + "actual aggregate wire bytes remain charged across forward and inverse", + "actual row count remains charged across phases and rejects total10000 overflow", + "actual public topology bearer reader observes final gateway without operation binding or namespace joins", + "actual public topology preserves unknown numeric gateway despite matching BGP cache/session strings", + "actual public topology bearer and server scope/disabled checks refuse before native I/O", + "actual public topology pause endpoint ABA and cancellation retire at first response without publication" + ], + "requests": 151, + "nativeWrites": 0, + "priorCheckpointsPreserved": 3, + "completeCheckpointSqlJsonInventoryUnchanged": true, + "realSql": true, + "realHttp": true, + "publicTopologyBearerRuntimeCasesAccepted": 4, + "routerOsReplies": "declared synthetic localhost HTTP", + "syntheticEncryptedSourcePlans": true, + "genuinePrepareForReadOnlySourceFixtures": false, + "strongOriginNamespaceUnsupported": true, + "logCoverage": "unknown", + "reservedDispatchEnabled": false, + "nativeChrAccepted": false, + "fullRoutingComplete": false, + "wholeInstallation2GbAccepted": false, + "overallCapsulePassed": false, + "terminalRestoreComplete": false, + "followingFailure": "Applied restore receipt was published before its exclusive owner finished final cleanup; immediate stale-callback test raced that still-owned exclusive and received409. Test now waits bounded actual whenOpen before stale replay check.", + "ownedResourcesRemoved": true, + "remotePacketRemoved": true, + "originalServicesUnchanged": true +} diff --git a/docs/mcp-maintenance-namespace-fix-source-applied-20261103.json b/docs/mcp-maintenance-namespace-fix-source-applied-20261103.json new file mode 100644 index 00000000..141f34df --- /dev/null +++ b/docs/mcp-maintenance-namespace-fix-source-applied-20261103.json @@ -0,0 +1,29 @@ +{ + "date": "2026-11-03", + "status": "source-applied-unit17-build-artifact2-passed-linux-pending", + "files": [ + { + "path": "backend/scripts/writer-profile/maintenance-resolution.mts", + "sha256": "1bb82e8d4f1747ee962a8236342c99c6b0270c517742e42b7184249f707ca140" + }, + { + "path": "backend/scripts/database-maintenance-resolution.test.mts", + "sha256": "32a2219a88848d4e41dbef21710569147f4a46bb6b2dbfe8af2b91a1aa9c8dbf" + } + ], + "changes": [ + "Observe optional mmapp_control presence inside the same read-only transaction; never require its installation", + "Preserve original observation failure when rollback/unlock/release are confirmed; refuse actual cleanup uncertainty" + ], + "checks": { + "targetedOperatorTestsPassed": 17, + "focusedLintExitCode": 0, + "backendBuildExitCode": 0, + "operatorArtifactNode": "22.23.4", + "operatorArtifactTestsPassed": 2, + "operatorModules": 19 + }, + "newLinuxAcceptanceRequired": true, + "newLinuxAccepted": false, + "databaseOrChrChanges": false +} diff --git a/docs/mcp-maintenance-resolution-source-applied-20261103.json b/docs/mcp-maintenance-resolution-source-applied-20261103.json new file mode 100644 index 00000000..68471c26 --- /dev/null +++ b/docs/mcp-maintenance-resolution-source-applied-20261103.json @@ -0,0 +1,85 @@ +{ + "date": "2026-11-03", + "status": "source-applied-local-checks-passed-linux-pending", + "patchSha256": "17f14726932ed73c7d3afea425ea02a75b98ca85a7e78f8cd250f934b38546d2", + "filesChanged": 12, + "candidateReport": "tmp/mcp-maintenance-resolution-candidate-20261103/candidate-report.json", + "behavior": "Explicit host operator inspection and separate approval accept observed current database state; historical unknown outcomes stay unknown. Startup refuses new uncovered uncertainty before SQL.", + "checks": { + "systemDatabaseProfileExitCode": 0, + "backendBuildExitCode": 0, + "operatorModules": 19, + "productionNode": "22.23.4", + "operatorArtifactTestsPassed": 2, + "selectedTestsPassed": 50, + "selectedTestsSkipped": 2 + }, + "nativeLinuxPosixAccepted": false, + "genuineMaintenanceSqlAccepted": false, + "serverChanged": false, + "deploymentPerformed": false, + "fullPlanComplete": false, + "files": [ + { + "path": "backend/package.json", + "appliedSha256": "38e9d101efe13aec2089fdcb081c972d4eb41587a99e0f6d39b91563ffc120a4", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/index.ts", + "appliedSha256": "2b1abfdf1574251a56746cfac8e7cd58309cf5afbc82caae230e043a28c39f56", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/db/bootstrap.ts", + "appliedSha256": "8b0df1e81fc3fe01b9959f4390f348f9a743f43fb987277eab0e3d75b8c11f07", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/services/database-maintenance-startup.ts", + "appliedSha256": "f49efd2fcdc16eb0af6af7c8de4f2e95ffe692da39e89590b86ba7e96d2f97bf", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/services/database-maintenance-resolution.ts", + "appliedSha256": "e3c26400ba17264516e8a16a624d91786ad20c0d98c62f1e62c91b2e20f89fc3", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/services/database-maintenance-resolution.test.ts", + "appliedSha256": "11f1f4d2d7e4b568943c69d9857df8a4af7580c180dfba7cf3b20eb9a525a30c", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/services/database-restore-audit.ts", + "appliedSha256": "84d50bbe98bcfea953c43a4b45bb12878dd2b1d00bd32f7a564406919f0ddaf4", + "candidateNormalizedExact": true + }, + { + "path": "backend/scripts/database-writer-profile-operator.mts", + "appliedSha256": "ee8f2b555189e8bd81d02eaf89825e746efaafe0f04cc70f79c462c229ad487c", + "candidateNormalizedExact": true + }, + { + "path": "backend/scripts/writer-profile/maintenance-resolution.mts", + "appliedSha256": "0db03d789132dc12e490ec3de6f49b8d4d551db0b9a34bf2dca36656993c2a09", + "candidateNormalizedExact": true + }, + { + "path": "backend/scripts/database-maintenance-resolution.test.mts", + "appliedSha256": "29664cbf2140a97b93cb59356b667908453233428349ecc422736a91a4df7cbd", + "candidateNormalizedExact": true + }, + { + "path": "backend/scripts/build-database-operator.mjs", + "appliedSha256": "66df17a4a72b341a420e3aec3a9afc87d273438c8aefed6239992047baecece2", + "candidateNormalizedExact": true + }, + { + "path": "backend/scripts/build-database-operator.test.mjs", + "appliedSha256": "4801a6c3f308be7fa1335564981ee82a6854f6319bd09e04f8a4682bf4e8f003", + "candidateNormalizedExact": true + } + ], + "followUpFixReport": "docs/mcp-maintenance-namespace-fix-source-applied-20261103.json" +} diff --git a/docs/mcp-oauth-database-admission-source-applied-20261103.json b/docs/mcp-oauth-database-admission-source-applied-20261103.json new file mode 100644 index 00000000..199cc503 --- /dev/null +++ b/docs/mcp-oauth-database-admission-source-applied-20261103.json @@ -0,0 +1,30 @@ +{ + "date": "2026-11-03", + "status": "source-applied-http6-system-database-build-lint-passed", + "patchSha256": "fa44982f020067b4342e65b02b60d3dbe957e0d42afca43ad73c97aad3e7f0c9", + "files": [ + { + "path": "backend/src/routes/mcp-oauth.ts", + "appliedSha256": "d2ea5cb6b7c4fdf0c370d8ffafc7e64792153e43260376f5c093b680449942dd" + }, + { + "path": "backend/src/routes/mcp-oauth-database-admission.test.ts", + "appliedSha256": "c4c8637d40d77686b69c191fb2a369bbe6d3f8a512e6fcd2ac4642fd28c31873" + }, + { + "path": "backend/package.json", + "appliedSha256": "ceed6c2f453afbc6d9a2a888e56ffd988c4df5249e76bdcbfdd914de97f384b7" + } + ], + "behavior": "Existing OAuth handlers enter shared database admission before SQL; active token transactions drain before replacement; sticky closure refuses OAuth with503/no-store until explicit repair. Discovery remains available without SQL.", + "focusedActualFastifyCasesPassed": 6, + "systemDatabaseProfileExitCode": 0, + "systemDatabaseProfilePlatform": "Windows functional tests; native POSIX case explicitly skipped", + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "genuinePgAccepted": false, + "linuxCurrentImageAccepted": false, + "deploymentPerformed": false, + "fullDbComplete": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-routing-authority-fence-batching-source-applied-20261103.json b/docs/mcp-routing-authority-fence-batching-source-applied-20261103.json new file mode 100644 index 00000000..9e38d226 --- /dev/null +++ b/docs/mcp-routing-authority-fence-batching-source-applied-20261103.json @@ -0,0 +1,22 @@ +{ + "date": "2026-11-03", + "status": "source-applied-native24-profile-build-lint-passed", + "files": [ + "backend/src/modules/mcp/routing-network-native-session.ts", + "backend/src/modules/mcp/routing-network-native-session.test.ts", + "backend/src/modules/mcp/routing-community-scoped-acceptance.ts" + ], + "behavior": "Complete current authority/source/policy checks surround one fence batch; direct native dispatch also retains fresh before/after runtime authorization. Primitive SQL/file leaves retain original deadline/signal checks without recursively repeating full runtime authorization. No decision or connection credentials are cached.", + "targetedNativeSessionCasesPassed": 24, + "completeNativeReadersProfileExitCode": 0, + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "diagnostic": "Synthetic SQL/runtime helper logs bounded effect status/reason/counters and fixed path names before assertions; no passwords/tokens/raw rows.", + "originalNativeDeadlineChanged": false, + "nativeRequestByteRowBudgetsChanged": false, + "memoryCapsChanged": false, + "genuineLinuxCorrectionAccepted": false, + "secondLinuxFailureCauseFullyProven": false, + "wholeInstallation2GbAccepted": false, + "fullRoutingComplete": false +} diff --git a/docs/mcp-routing-fib-switch-source-applied-20261103.json b/docs/mcp-routing-fib-switch-source-applied-20261103.json new file mode 100644 index 00000000..a401cd72 --- /dev/null +++ b/docs/mcp-routing-fib-switch-source-applied-20261103.json @@ -0,0 +1,21 @@ +{ + "date": "2026-11-03", + "status": "source-applied-unit14-build-lint-and-two-native-readers-passed", + "files": [ + { + "path": "backend/src/services/routing-topology-observation.ts", + "sha256": "27a9f53191f47478518b9d33e815b9ddc758a9a1be1fb53e67adf11211970c68" + }, + { + "path": "backend/src/services/routing-topology-observation.test.ts", + "sha256": "8a7cc989b3444f466316b2baac58e7ebd828bdafbc8410a80ddda602bd68904c" + } + ], + "nativeWireObservation": "Both RouterOS7.24.5 tables encode enabled fib switch as empty string. fib-specific switch decoder accepts it; invalid/disabled/absent/nonfib and arbitrary boolean empty values stay unknown.", + "primarySemanticsReference": "https://manual.mikrotik.com/docs/cli-reference/routing/table/", + "unitTestsPassed": 14, + "nativeReport": "docs/mcp-two-chr-current-reader-acceptance-20261103.json", + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "fullRoutingDirectionComplete": false +} diff --git a/docs/mcp-routing-forward-context-source-applied-20261103.json b/docs/mcp-routing-forward-context-source-applied-20261103.json new file mode 100644 index 00000000..08bb2e5d --- /dev/null +++ b/docs/mcp-routing-forward-context-source-applied-20261103.json @@ -0,0 +1,35 @@ +{ + "date": "2026-11-03", + "status": "source-applied-bridge46-effects70-native-profile-build-lint-passed", + "files": [ + { + "path": "backend/src/modules/mcp/routing-network-configuration-bridge.ts", + "sha256": "cad1b6a8951e5cbb301331752e17fc032738c04206ab5b699d3545cdd09ff4e6" + }, + { + "path": "backend/src/modules/mcp/routing-network-configuration-bridge.test.ts", + "sha256": "1666afb5d533f647bd4c33b6aa949ff5d705516f567bfb88c8b4590842b85bde" + }, + { + "path": "backend/src/modules/mcp/routing-community-scoped-service.ts", + "sha256": "3a8c92e40fe79a37e93b39645d33a5df842ccf5f43ac2589b03a33d920e37aee" + }, + { + "path": "backend/src/modules/mcp/routing-original-effects-caller.test.mjs", + "sha256": "825d4bcda0dc6b9b61984b44c6ded76ffd8a42f2b69b30825e22a4734be8e79b" + } + ], + "behavior": "The private forward-round context validates original shared owner/principal/source/checkpoint at admission; its first current native configuration observation is mandatory immediately before the first snapshot. Removed unused eager native read. Both before/after reads of each snapshot and final publication read stay mandatory. Existing independent/shared one-shot bridge behavior unchanged.", + "boundedFailureCategory": "Optional round/publication errors carry only a fixed stage and an allowlisted error code, never raw exception details.", + "bridgePermanentCasesPassed": 46, + "effectOriginCallerControllerCasesPassed": 70, + "completeNativeReadersProfileExitCode": 0, + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "sourceDeadlineExpanded": false, + "nativeBudgetsExpanded": false, + "memoryCapsExpanded": false, + "genuineLinuxAccepted": false, + "fullRoutingComplete": false, + "deploymentPerformed": false +} diff --git a/docs/mcp-routing-original-effects-source-applied-20261103.json b/docs/mcp-routing-original-effects-source-applied-20261103.json new file mode 100644 index 00000000..655bde71 --- /dev/null +++ b/docs/mcp-routing-original-effects-source-applied-20261103.json @@ -0,0 +1,76 @@ +{ + "date": "2026-11-03", + "status": "source-applied-native-readers-build-lint-passed", + "patchSha256": "b33a745cde76abd046e02e9f3cfbe6e63aaaead13eee6dfc4023d0f52c278978", + "files": [ + { + "path": "backend/src/modules/mcp/routing-community-context.ts", + "appliedSha256": "b9c199b30a299fa4b4e339b35a9df5c725fa6b6ada2b2adf8e2699ca415157bb" + }, + { + "path": "backend/src/modules/mcp/routing-community-scoped-checkpoint.ts", + "appliedSha256": "492c43a8b80593e669d42f3658aaa6563a5199302ae2dae5ddc6f4f5b432f68a" + }, + { + "path": "backend/src/modules/mcp/routing-community-scoped-service.ts", + "appliedSha256": "83754daf7eca5259dba2afa859679364d7b296f8f4305ee9d43574ac1ab57d6a" + }, + { + "path": "backend/src/modules/mcp/routing-controller-baseline.ts", + "appliedSha256": "d7aa97275fd00c3371c4b6fec09c176c6ea312e805030a382a32d79e7d42b708" + }, + { + "path": "backend/src/modules/mcp/routing-network-execution-origin.test.mjs", + "appliedSha256": "0424c04ada0464dd7819ef5fc9622767d13f198a7f5759cf9d3851e5c7bedd72" + }, + { + "path": "backend/src/modules/mcp/routing-network-execution-session.ts", + "appliedSha256": "8862056444f16b2aaee7a75fa41cf5346b7e4f2e68a8399d2829e2e9e20f30fa" + }, + { + "path": "backend/src/modules/mcp/routing-original-effects-caller.test.mjs", + "appliedSha256": "994ca8f71a02cb7dc8a6daac8959ea28cedb962c68cdb9406dfe1c277abf6c62" + }, + { + "path": "backend/src/modules/mcp/routing-original-effects-controller.test.mjs", + "appliedSha256": "f42d2c9374d8be466ba0106bb7fff9b7a4ab1e1fe8b55ce409cd7aa4280e4d08" + }, + { + "path": "backend/src/modules/mcp/routing-original-effects.test.mjs", + "appliedSha256": "4e8ceab93dafff221a82eb3ab1ccbafbeff87d0938a4a0b2b98320bec94b69e9" + }, + { + "path": "backend/src/modules/mcp/routing-original-effects.ts", + "appliedSha256": "a3145b08e99ad056975080244dfd30c33e60c85a7a2338e00e8685683e9922eb" + }, + { + "path": "backend/package.json", + "appliedSha256": "ceed6c2f453afbc6d9a2a888e56ffd988c4df5249e76bdcbfdd914de97f384b7" + } + ], + "behavior": "Prepared destination selection, bounded execution-bound pre-PATCH observation and two post-CONFIG_APPLIED observations share verified running origin and original deadline; signed effect vector remains separate from successful configuration receipt.", + "permanentComponentCasesPassed": 69, + "completeNativeReadersProfileExitCode": 0, + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "activation": { + "persistentScopedRevision3OwnerWired": true, + "publicManualRevision2OwnerEnabled": false, + "scopedSchedulerEnabled": false, + "existingScopeActivationGuardPreserved": true, + "autoRecoveryEnabled": false + }, + "limits": { + "causalAttributionProven": false, + "bgpOriginVerified": false, + "recursiveGraphVerified": false, + "forwardingVerified": false, + "logCoverage": "unknown" + }, + "genuineSqlRuntimeAccepted": false, + "nativeChrEffectAccepted": false, + "linuxCurrentImageAccepted": false, + "deploymentPerformed": false, + "fullRoutingComplete": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-routing-shared-fib-source-applied-20261103.json b/docs/mcp-routing-shared-fib-source-applied-20261103.json new file mode 100644 index 00000000..5b0344e0 --- /dev/null +++ b/docs/mcp-routing-shared-fib-source-applied-20261103.json @@ -0,0 +1,42 @@ +{ + "date": "2026-11-03", + "status": "source-applied-native-readers-profile-build-lint-passed", + "files": [ + { + "path": "backend/src/services/routing-table-fib.ts", + "sha256": "a95a850c7ba01685c458db5f3192bfea021d88f1e104d4331572bcdf8ca11426" + }, + { + "path": "backend/src/services/routing-table-fib.test.ts", + "sha256": "8e83c8598259eaa2f131566197252ddfe03ffb9c6af160c0109d958eccea74f2" + }, + { + "path": "backend/src/services/routing-topology-observation.ts", + "sha256": "88113aaf89518f5d72b167d41e6378ea3dd4849debb4df37b5a7faa6f6058e3d" + }, + { + "path": "backend/src/modules/mcp/control-path-context.ts", + "sha256": "01723ae1c2a4d5918dc66d1fae7f20e78f24ae5a00c9454fe2884402899315dd" + }, + { + "path": "backend/src/modules/mcp/routing-community-context.ts", + "sha256": "33b25d4b4a2ec0b9407ed6edc0b7ca8cd23e143301316f4ae6aad81b465abfb1" + }, + { + "path": "backend/package.json", + "sha256": "f76be3c4d180839c0dcf3a8d64e7d9691a04023ccbc22018bd1bf91e206769e4" + } + ], + "behavior": "One FIB switch interpretation for topology, control-path and Community source/destination/resolver tables. Empty switch value supported; missing FIB and invalid/disabled/ambiguous table flags refuse.", + "nativeReadersProfileExitCode": 0, + "targetedSharedAndTopologyTestsPassed": 17, + "controlAndCommunityModelTestsPassed": 9, + "nativeLocalHttpTestsPassed": 6, + "localHttpPermissionRetry": "Initial6 EACCES failures were environmental; isolated permitted-localhost retry passed6/6, no product change needed.", + "backendBuildExitCode": 0, + "focusedLintExitCode": 0, + "priorNativeReaderReport": "docs/mcp-two-chr-current-reader-acceptance-20261103.json", + "newCurrentNativeCapturePending": false, + "fullRoutingDirectionComplete": false, + "currentNativeReport": "docs/mcp-two-chr-shared-fib-reader-acceptance-20261103.json" +} diff --git a/docs/mcp-routing-topology-observation-source-applied-20261103.json b/docs/mcp-routing-topology-observation-source-applied-20261103.json new file mode 100644 index 00000000..bfc1c9b6 --- /dev/null +++ b/docs/mcp-routing-topology-observation-source-applied-20261103.json @@ -0,0 +1,37 @@ +{ + "date": "2026-11-03", + "status": "source-applied-unit-and-build-passed", + "patchSha256": "2b5900fea0d4d36fe6324987d7967236242eaf573f04fb662a8d023d3f73c1ad", + "files": [ + { + "path": "backend/src/modules/mcp/routing-controls.ts", + "appliedSha256": "793b2714dac4bfd4038ef1715e328c5e65647ed0dd10b2f1aab7e5ba9b21b1ac", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/modules/mcp/routing-network-native.sql.test.ts", + "appliedSha256": "eb4922c3a00747af41954afbd9fce9980477129cb5b2baad1ec3e92270be7bee", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/services/routing-topology-observation.ts", + "appliedSha256": "9fef3d9476a829981afa3fe92567c2da1b8c282167fdd6c61eff243f33ae3a64", + "candidateNormalizedExact": true + }, + { + "path": "backend/src/services/routing-topology-observation.test.ts", + "appliedSha256": "3dc1a1f6870f973ff45936ae637f3f85460ed748f6a710a3a70b60506c6025d4", + "candidateNormalizedExact": true + } + ], + "newPublicTools": 0, + "existingPublicTool": "mm_routing_topology_get", + "testsPassed": 13, + "backendBuildExitCode": 0, + "genuinePublicSqlGroupsAccepted": 0, + "actualChrAccepted": false, + "recursiveChainVerified": false, + "routeOriginVerified": false, + "forwardingVerified": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-two-chr-current-reader-acceptance-20261103.json b/docs/mcp-two-chr-current-reader-acceptance-20261103.json new file mode 100644 index 00000000..2f3346a1 --- /dev/null +++ b/docs/mcp-two-chr-current-reader-acceptance-20261103.json @@ -0,0 +1,57 @@ +{ + "date": "2026-11-03", + "status": "two-chr-current-reader-accepted", + "cases": [ + { + "source": "194.226.121.150", + "version": "7.24.5 (stable)", + "requests": 45, + "actualReaderGets": 11, + "conservationSections": 17, + "configurationConserved": true, + "observedRoutes": 4, + "unknownReasons": { + "multipath-selection-unknown": 2 + }, + "sourcePins": { + "backend/src/services/routing-topology-observation.ts": "27a9f53191f47478518b9d33e815b9ddc758a9a1be1fb53e67adf11211970c68", + "backend/scripts/acceptance/chr-recursive-observation.mjs": "837390d9a32bf7a254934faf8167d7d360724bbd843f3d6213a4668ea0239de0", + "backend/src/test/chr-vrf-companion-telemetry.ts": "08ed399a57f56bb0c5aa1cc5c224b8b91ca7ab65cec03c3faf27ed5e983750c2" + }, + "report": "tmp/mcp-two-chr-current-read-183d9b771f1bd3a73edc95aac1097fbd/chr1/report.json", + "reportSha256": "2b0c7be7e778e862dc142959a2b11ed9728ff1145f64efc4cfa212f6fe04403e" + }, + { + "source": "104.165.244.7", + "version": "7.24.5 (stable)", + "requests": 45, + "actualReaderGets": 11, + "conservationSections": 17, + "configurationConserved": true, + "observedRoutes": 5, + "unknownReasons": { + "multipath-selection-unknown": 2 + }, + "sourcePins": { + "backend/src/services/routing-topology-observation.ts": "27a9f53191f47478518b9d33e815b9ddc758a9a1be1fb53e67adf11211970c68", + "backend/scripts/acceptance/chr-recursive-observation.mjs": "837390d9a32bf7a254934faf8167d7d360724bbd843f3d6213a4668ea0239de0", + "backend/src/test/chr-vrf-companion-telemetry.ts": "08ed399a57f56bb0c5aa1cc5c224b8b91ca7ab65cec03c3faf27ed5e983750c2" + }, + "report": "tmp/mcp-two-chr-current-read-183d9b771f1bd3a73edc95aac1097fbd/chr2/report.json", + "reportSha256": "524097e9817b8299df3b6307ae40e5404e10fea266162f9eb022dff243d4c970" + } + ], + "totalNativeGets": 90, + "nativeMutations": 0, + "ownedChildrenRetired": true, + "ownedPortsAbsent": true, + "sourceTransport": "Pinned VPS SSH to fixedCHR80, separately verified pinned nativeSSH peers", + "httpLegTlsVerified": false, + "publicMcpSqlAdmissionAccepted": false, + "routeOriginVerified": false, + "recursiveChainVerified": false, + "forwardingVerified": false, + "threeNodePathAccepted": false, + "wholeInstallationMemoryAccepted": false, + "fullPlanComplete": false +} diff --git a/docs/mcp-two-chr-shared-fib-reader-acceptance-20261103.json b/docs/mcp-two-chr-shared-fib-reader-acceptance-20261103.json new file mode 100644 index 00000000..a13cc169 --- /dev/null +++ b/docs/mcp-two-chr-shared-fib-reader-acceptance-20261103.json @@ -0,0 +1,62 @@ +{ + "date": "2026-11-03", + "status": "exact-current-shared-fib-reader-two-chr-accepted", + "cases": [ + { + "source": "194.226.121.150", + "version": "7.24.5 (stable)", + "nativeGets": 45, + "readerGets": 11, + "conservationSections": 17, + "configurationConserved": true, + "observedRoutes": 4, + "unknownReasons": { + "multipath-selection-unknown": 2 + }, + "sourcePins": { + "backend/src/services/routing-table-fib.ts": "a95a850c7ba01685c458db5f3192bfea021d88f1e104d4331572bcdf8ca11426", + "backend/src/services/routing-topology-observation.ts": "88113aaf89518f5d72b167d41e6378ea3dd4849debb4df37b5a7faa6f6058e3d", + "backend/scripts/acceptance/chr-recursive-observation.mjs": "837390d9a32bf7a254934faf8167d7d360724bbd843f3d6213a4668ea0239de0", + "backend/src/test/chr-vrf-companion-telemetry.ts": "08ed399a57f56bb0c5aa1cc5c224b8b91ca7ab65cec03c3faf27ed5e983750c2" + }, + "report": "tmp/mcp-two-chr-current-read-103b434a9dcf642ba1a5577642febdd1/chr1/report.json", + "reportSha256": "b725386abd71070647c2098cad2e2ed60e3ab0e3d21349b8ece09a10bd912ae6" + }, + { + "source": "104.165.244.7", + "version": "7.24.5 (stable)", + "nativeGets": 45, + "readerGets": 11, + "conservationSections": 17, + "configurationConserved": true, + "observedRoutes": 5, + "unknownReasons": { + "multipath-selection-unknown": 2 + }, + "sourcePins": { + "backend/src/services/routing-table-fib.ts": "a95a850c7ba01685c458db5f3192bfea021d88f1e104d4331572bcdf8ca11426", + "backend/src/services/routing-topology-observation.ts": "88113aaf89518f5d72b167d41e6378ea3dd4849debb4df37b5a7faa6f6058e3d", + "backend/scripts/acceptance/chr-recursive-observation.mjs": "837390d9a32bf7a254934faf8167d7d360724bbd843f3d6213a4668ea0239de0", + "backend/src/test/chr-vrf-companion-telemetry.ts": "08ed399a57f56bb0c5aa1cc5c224b8b91ca7ab65cec03c3faf27ed5e983750c2" + }, + "report": "tmp/mcp-two-chr-current-read-103b434a9dcf642ba1a5577642febdd1/chr2/report.json", + "reportSha256": "4687af045b119b80e6c75b393c001c1572d25f897e3ec4e342fbdd340c5c54c4" + } + ], + "totalNativeGets": 90, + "nativeMutations": 0, + "sourcePinsCurrent": true, + "ownedChildrenRetired": true, + "ownedPortsAbsent": true, + "transport": "Pinned VPS SSH to fixedCHR80, separately verified nativeSSH peers", + "httpLegTlsVerified": false, + "observerPlatform": "Windows local functional observer through the Linux VPS; no Windows capacity/RAM claim", + "publicMcpSqlAdmissionAccepted": false, + "semanticProof": false, + "routeOriginVerified": false, + "recursiveChainVerified": false, + "forwardingVerified": false, + "twoChrFullSchemeComplete": false, + "thirdChrNeededForThisRead": false, + "fullPlanComplete": false +} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..b0671d55 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,1026 @@ +# MCP: подключение и состояние реализации + +Актуальное состояние на 3 ноября 2026: [план и оставшиеся направления](mcp-implementation-plan.md), [машиночитаемый статус](mcp-current-progress.json). Linux-приёмка обслуживания БД прошла16 сценариев; текущий reader принят на двух CHR без изменения конфигурации. [Порядок ручной проверки после неопределённого восстановления](mcp-database-maintenance.md). Полный план, полная двухузловая схема и лимит всей установки2ГБ остаются открыты. Числа и разрешённые окна в прежних датированных срезах ниже являются историческими. + +**Состояние на 29 октября:** speed-test 3/3 и manual collectors 4/4 завершены; REST: 200 mediated / 62 excluded / 0 pending. Весь план: **осталось 7 направлений**. Приняты локальные NetFlow worker/services и компоненты routing forward rounds/private handoff. Полный autoRecovery, внешняя сетевая приёмка и нагрузка ≤2 ГБ остаются открыты. [Текущий реестр](mcp-current-progress.json), [последняя приёмка routing](mcp-routing-rounds-handoff-acceptance-20261029.json). + +**29 октября — подготовлен фиксированный key/HMAC-примитив для OP-M4.** [Локальный отчёт](mcp-node-key-effect-progress-20261029.json): 42 точечные проверки, независимый review, полная peer-регрессия, build и строгая проверка типов прошли. Чтение ровно 32 байт и HMAC выполняются без `await`; проверяются текущий hash/inode, исходные actor/owner signals и wall/monotonic deadline, scratch key очищается до close. Примитив пока не подключён к dispatcher: он не удостоверяет Q/права/подписанный источник и не сокращает старую цепочку проверок. Полная same-owner цепочка, measured-latency positive и реальное окно ещё открыты. **OP-M: 3/4, S3: 5/10.** Новых процессов/pool/миграций нет; оставшееся окно стенда сохранено, нагрузочная приёмка всей установки ≤2 GB не завершена. + +**29 октября — OP-M3 принят как компонент, готово 3/4 шагов окна.** [Guarded managed/external sources](mcp-guarded-peer-sources-acceptance-20261029.json): 57 focused tests, 9 actual PG/localhost групп, 24 legacy PG-группы, native 77/77; полные peer/storage-наборы и build прошли. External metadata работает без key file и проверяет current encrypted profile/generation/directory; оба sources требуют genuine original-window guard. Реальный race HELD → потеря owner во время Q воспроизведён и исправлен через непрерывный lifetimeSignal connector → core → node → window. Последний внутренний шаг **OP-M4 открыт**: fixed effects на том же PID, положительная measured-latency и эксплуатационная приёмка. **S3: 5/10 закрыто, 5 осталось.** Окно стенда не использовано; текущий [operating-срез](mcp-operating-progress-20261029.json) сохраняет полный план и требование всей установки ≤2 GB. + +**28 октября — managed metadata OP-M3 принят как компонент.** [29 новых тестов и 4 actual PG/localhost-сценария](mcp-operator-peer-metadata-managed-acceptance-20261028.json): genuine observation/prerequisite и текущий signed grant проверяются без повторной выдачи credentials, Q или native. SQL сравнивает исходные private endpoint/password внутри PostgreSQL и возвращает boolean; неверные значения отклоняются даже при неизменном epoch. Штатный endpoint trigger сохранён: восстановление полей не оживляет старый origin. Build и полный peer-набор прошли. **External provider пока отсутствует и отказывает без fallback, OP-M3 открыт.** Готово по-прежнему 2/4 внутренних шага окна; S3 **5/10 закрыто, 5 осталось**. Одно окно стенда сохранено; весь MCP-план и ≤2 GB RAM остаются открыты. + +**28 октября — same-lease metadata принят как компонент.** [19 новых тестов и 7 actual PostgreSQL-сценариев](mcp-public-registry-metadata-acceptance-20261028.json) подтвердили работу без secret files, исходные role/catalog/source validators, точный PID/exclusive lock, отказ конкурента и физическое закрытие при abort/busy/drift. Дополнительные pool/роль/миграция не созданы. Build и полный peer-набор прошли. В остатке operator window теперь **2 из 4 внутренних шагов готовы**: OP-M1 public files и OP-M2 public same-lease inspection; OP-M3 private endpoint metadata и OP-M4 fixed effects/latency/stand acceptance открыты. Это не закрывает S3-06: **основной счётчик 5/10, осталось 5**, одно окно стенда сохранено; весь MCP-план и ≤2 GB RAM открыты. + +**28 октября — public metadata prerequisite реализован.** [17/17 новых тестов и актуальная регрессия](mcp-operator-public-metadata-progress-20261028.json): core/node читают только свежие подписанные публичные файлы, не получают ключи/credentials или SQL lease и не создают strong authority. Настоящая configured core → node композиция v1/v2 проверена; backend build и полный peer-набор прошли. Это завершённый внутренний шаг S3-06, а не готовое operator window. Далее нужны fixed same-lease metadata session, private current endpoint/credential commitment proof и fixed effect dispatcher с измеренной latency-приёмкой. [Точный effect ledger](bandwidth-operator-effect-ledger.md) сохраняет обе HMAC-фазы и реальные before/after границы. **S3: закрыто 5/10, осталось 5; одно разрешённое окно сохранено, общий MCP-план и ≤2 GB RAM открыты.** + +**28 октября — текущий operating/performance-срез.** Диагностический v7 прошёл получение Docker quiescence и открытие source, затем отказал в registry SQL; точная SQL-причина не установлена. Независимый watchdog восстановил исходные policy/running и health 200. Последующий read-only postflight подтвердил четыре исходных контейнера и отсутствие всех 17 временных путей. Из двух дополнительно разрешённых окон использовано одно; **одно осталось и не расходуется до положительной локальной проверки с измеренными задержками Docker**. Полный пятиконтейнерный scan оценён в 464,748 ms; прежние 308 scans требуют около 143 секунд при неизменном допуске 5 секунд. Объединение парных inspect/authorize и параллельное чтение публичных файлов прошли unit-проверки, но производительность композиции ещё не принята: realistic-latency профиль отказал до credentials/COMMIT, без действий с backend. Последние backend build и полный peer-набор прошли. [Текущий отчёт](mcp-2gb-operating-progress-20261028.json), [непринятый проект изменения контракта](bandwidth-operator-metadata-effect-contract.md). **S3 остаётся 5/10; полный план и требование всей установки ≤2 GB сохраняются.** + +28 октября 2026, следующий [компонентный срез](mcp-writer-speed-progress-20261028.json): genuine MCP speed — 6 owned SQL/HTTP-групп, writer/kernel/fixed native — 31 группа, native-регрессия — 77 тестов. Публичный speed action и все runtime callers ещё открыты. В план добавлено требование **вся установка с MCP ≤2 GB RAM**, [методика измерений и кандидат Compose](mcp-2gb-resource-acceptance.md); нагрузочное соответствие пока не подтверждено. Два однократно разрешённых operating-прогона завершились отказом, следующий подготовленный пакет сам по себе разрешения на запуск не даёт. **Текущий S3 остаётся 5/10**, полный план не завершён. + +28 октября 2026, текущий интеграционный срез: [router guard, три full-speed original и durable run ID](mcp-speed-integration-progress-20261028.json). Приняты 18 guard SQL/HTTP-сценариев, v1/v2 same-control compatibility, scheduler 6 и REST 7 SQL/TCP-групп, 6 workflow + 4 run-ID SQL/HTTP-сценария. MCP original unit 14, workflow/run identity 41, native 70; build/TS/lint/auth/full MCP/speed baseline прошли. Новые компоненты не включают runtime all-callers или публичный MCP speed action; положительный MCP SQL/HTTP-профиль ещё нужен. **Этап остаётся 5/10**, [точный оставшийся объём](mcp-speed-progress.md). + +28 октября 2026, дополнение: [общий speed workflow прошёл 6/6 сквозных owned PostgreSQL/HTTP-сценариев](mcp-speed-workflow-acceptance-20261028.json): genuine REST/JWT, signed execution host, completion receipt, пауза, after-ping и история. Проверены потеря возврата COMMIT и native socket drop с `unknown` без replay. Scheduled speed provider реализован и unit-проверен. Enrollment Q и wire mapping этого профиля синтетические; все callers и публичный MCP speed action ещё не включены. **Текущий счётчик остаётся 5/10**, весь MCP-план открыт. [Актуальный трекер](mcp-speed-progress.md). + +28 октября 2026: [scoped bandwidth SQL/HTTP-приёмка](mcp-bandwidth-native-window-acceptance-20261028.json) прошла: v1 25 сценариев с dump/restore, повтор v2 23 сценария / 89 license GET / 2 btest POST; native 57/57 и полный peer script — exit 0. Настоящий SQL/transport barrier и operator writer lifecycle приняты как компоненты. **Текущий speed-test этап: закрыто 5/10, осталось 5**; внешний stop/restart issuer, wire mapping, все callers, публичный speed workflow и финальная приёмка ещё открыты. [Трекер](mcp-speed-progress.md), [полный оставшийся объём](mcp-remaining-work-20261026.md). + +2026-10-26: [полный server poll принят через публичный MCP](mcp-server-poll-public-acceptance-20261026.json). Инструмент `mm_server_poll_plan` требует человеческого подтверждения; сохраняет исходный допуск, использует ограниченный журнал и возвращает безопасный снимок. Сквозная приёмка с обслуживанием истории завершилась за 151506 ms при лимите 180 секунд; проверены read-only квитанция, отсутствие повторного native и точное восстановление журнала. Каталог: **358 инструментов; 195 mediated / 62 excluded / 5 pending**. MCP admissions owner8/global128 удерживаются до retention GC; это не лимит одновременных запросов. Штатный UI REST handler сохранён. [Актуальные оставшиеся задачи и критерии приёмки](mcp-remaining-work-20261026.md). + +2026-10-26: [повторная приёмка экспорта существующих IPsec-сертификатов](mcp-existing-ipsec-export-reverification-20261026.json) прошла для client/server/other/CA через REST, MCP с человеческим подтверждением и портал владельца. Явные private/CA-флаги обязательны; модель получает только квитанцию, зашифрованный файл доступен владельцу 15 минут. Выполнены только синтетические проверки, без деплоя и реальных ключей CHR. Этот статус заменяет исторические записи ниже о выключенной активации или ожидаемом разрешении. + +[Poll-storage устанавливается штатной0039 и обслуживается worker](mcp-server-poll-storage-enrollment-acceptance-v1.json). Дважды выполнен реальный startup39; непустой старый журнал сохраняется с отказом до DDL. Independent reference и18pristine seeds проверены. Существующий worker выполняет ограниченный GC даже при отключённом MCP; реальные expired rows/atomicrefund и9lifecycle-unit прошли, новый timer не добавлен. Сам SQL predicate теперь держит retention setting `FOR SHARE`: прямой guarded DELETE также защищён от гонки с увеличением срока хранения.12SQL-lifecycle случаев, quota-bound4GET/fullmaintenance, два current39 dump/restore с7-табличной сверкой,6публичных диагностик и4роли IPsec/CA прошли. **Public poll пока не подключён:** нужны read-only receipt verify/reconcile, sealed human-approved MCP binding иwhole current-authority/REST приёмка. Storage128/8 — лимиты сохраняемых admissions доGC, а не concurrent slots; рабочую ёмкость необходимо проверить перед заменой REST. Coverage357/194mediated/62excluded/6pending; деплоя не было. + +Отдельно на current39 повторён полный148-effect сценарий с удалением истёкшего partition:4nativeGET,152публикации, ordinary writer блокируется доrelation SQL, known replay иlate revoke безновыхnative; настоящий dump/restore/tamper прошёл. Этот сценарий также использует новый quota-bound constructor. + +[Квоты и безопасная очистка полного poll проверены](mcp-server-poll-storage-acceptance-v1.json): до anchor подтверждается отдельный резерв8MiB+32KiB, максимум128попыток глобально и8на владельца. Существующий admission не получает новый claim token. Новый quota-bound исполнитель прошёл4nativeGET,147maintenance/151публикацию и312metered journal rows; replay не вызывает native и не расходует квоту. Настоящие lost COMMIT ACK, orphan quarantine, конкурентные лимиты, полный GC/atomicrefund и запрет удаления одним GUC прошли10SQL-сценариев. После реального dump/restore совпали все7таблиц, включая ciphertext/claim tokens; global/owner counters сверены,128-attempt cap сохранился.36новыхunit +regression/strict/lint/build прошли. **Схема пока isolated proposal:** штатная миграция, worker hook и public poll/actual sealed MCP-plan/current authority acceptance ещё впереди; normal manifest38 и coverage357/194mediated/62excluded/6pending не менялись. Новая private REST factory использует quota-bound session и без этой схемы отказывает; действующий публичный REST route не заменён. Деплоя не было. + +[Полный server poll: схема и внутренний исполнитель проверены](mcp-server-poll-enrollment-acceptance-v1.json). Штатная0038 устанавливает журнал и writer guards; общий manifest/reference теперь содержит38миграций, исторические fixtures остаются на0036. На временной БД прошли4nativeGET,148maintenance-действий,152публикации, исключение конкурентного writer до обращения к relation, повтор без native и настоящий dump/restore. Общий предел poll180s включает журнал/DDL; native15s, SQL5s и исходный срок допуска сохранены. Новые authority/REST factories прошли18unit-сценариев, но **публичный poll ещё не подключён**: нужны квота/retention журнала, real approved MCP-plan wiring и сквозная SQL/HTTP-приёмка именно этих factories. Экспорт существующих IPsec/CA повторно прошёл все4роли и owner portal на синтетических ключах. Coverage остаётся357/194mediated/62excluded/6pending; деплоя не было. + +[Диагностика теперь доступна через рабочие REST/MCP адаптеры](mcp-diagnostic-public-v2-acceptance-v1.json): public tools `mm_server_probe_plan` (6видов диагностики и human approval), `mm_server_probe_receipt_get` (страницы с текущим read-допуском) и `mm_server_probe_cancel` (немедленный stop с write-проверкой). REST использует тот же журнал/квоты, retention подключён к существующему worker. Все6команд, реальные локальные DNS UDP/TCP, большие страницы, отмена до/во время send и actual app/JWT проверены. Сетевые лимиты сохранены; проверки доступа/сохранение получили ограниченный30s reserve без продления private expiry. Coverage теперь **357tools/194mediated/62excluded/6pending**. На удалённый стенд изменения не развёртывались; общий DB-control, legacy-backfill и прочая приёмка полного плана ещё не закрыты. + +[Первичная схема v2 теперь устанавливается штатной0037](mcp-diagnostic-fresh-v2-installation-acceptance-v1.json): migration выполняется только при пустом legacy, непустые данные сохраняются с отказом до DDL. Проверены повторный startup, MCP/REST runtime на новой схеме и настоящий dump/restore. Legacy fixtures явно остаются на0036; для них нельзя фабриковать0037marker. Production adapters и retention lifecycle ещё не подключены; публичных pending остаётся7. Деплоя на стенд не было. + +[Проверено состояние первой установки на стенде](mcp-first-install-readiness-20261025.json): таблиц MCP/server_probe ещё нет, все новые MCP-миграции пока находятся в worktree. Следующее подключение пойдёт через fresh-only v2 migration с отказом при непустом legacy; отдельный backfill остаётся для промежуточных dev-установок и restore. Никаких удалённых изменений не выполнялось. + +[Legacy MCP orphan-приёмка прошла на настоящем PostgreSQL](mcp-diagnostic-legacy-orphans-acceptance-v1.json): completed receipt, карантин без anchor и отмена без anchor проверены по реальному sealed source с истёкшим original credential. Из8 записей3 подтверждены,5 отклонены; чтение не меняет immutable rows, v2 не устанавливается и native не вызывается. generationAuthenticated/migrationReady/conversionAllowed остаются false. + +[Источник и legacy snapshot связаны внутренней проверкой](mcp-diagnostic-source-pairing-acceptance-v1.json): данные журнала и source facts читаются на одном owned backend/transaction/snapshot с проверкой до и после. Нельзя заменить observation JSON-копией или перенести на другой SQL facade. На временном PostgreSQL проверены source/data pairing, различие двух БД одного кластера и защита от shadow type/operator через search_path. 20 новых тестов и связанная регрессия прошли. Исходные идентификаторы остаются внутренними; generationAuthenticated/READY/conversionAllowed=false. Независимое поколение источника, backfill и безопасное переключение ещё не реализованы. + +[Внутренняя операторская проверка legacy защищена и принята](mcp-diagnostic-legacy-inspection-acceptance-v1.json): актуальные права администратора проверяются до чтения и повторно на свежем backend после snapshot. JWT/app/scope не задаются JSON тела; callback и SQL ограничены сроками и отменой, поздний client закрыт. На настоящем PostgreSQL проверены отзыв прав между сбором/выдачей и отмена при удерживаемом relation lock; общая регрессия прошла. Публичный route не зарегистрирован, migrationReady/conversionAllowed=false. Следующие условия подключения — source DB authority, backfill и writer/DDL drain. + +[Исторические квитанции и согласованный read-only снимок проверены](mcp-diagnostic-legacy-snapshot-acceptance-v1.json): verifier различает authenticated receipt, approved original MCP source и context-only quarantine; недостаток доказательств блокирует перенос. Collector не возвращает отдельные идентификаторы или payload, требует явную read-only repeatable-read transaction и обнаруживает лимиты/неполноту. Реальный PostgreSQL подтвердил неизменность текущего snapshot при параллельном legacy writer и появление новой записи в следующем; original expired MCP source доступен без возобновления старых прав. 69+20 новых проверок и полная связанная SQL/HTTP/restore-регрессия прошли. Это внутренние компоненты: lifecycle/текущие права оператора принадлежат будущему host. Во всех результатах migrationReady/conversionAllowed=false; публичный переход схемы, backfill и writer/DDL drain не выполнялись. + +[Управляемые metadata/owner проверки теперь отменяются](mcp-diagnostic-metadata-cancellation-acceptance-v1.json): отдельный контекст запроса ограничивает owned SQL и callbacks, а поздний client не может продолжить работу после закрытия родительского окна. Проверка на настоящем PostgreSQL lock подтвердила завершение factory и HTTP/JWT handler до снятия блокировки, без native-вызова и admission. Независимый quarantine сохранён. Финальные runtime/regression/restore проверки прошли. Общие runtime/watchers и чужие planner transactions сохраняют свои правила; переключение production route и authenticated переход схемы v2 ещё не выполнены. + +[REST-подключение к v2 подготовлено и проверено](mcp-diagnostic-rest-storage-acceptance-v1.json): новый factory и отдельный HTTP plugin используют общий журнал, квоту и карантин, сохраняя ответ `{output}`. Проверены реальные JWT, запрет подмены идентичности, отключение клиента, BW fallback и работа без перечисления парка из 1001 сервера. Сквозной MCP runtime после выделения общего I/O также проходит приёмку. Это opt-in исходники: production startup пока использует старый REST route, переход схемы и публичное подключение ещё не выполнены. Полная native-проверка всех шести инструментов и bounded outer metadata/authority SQL остаются отдельными пунктами readiness. + +[Внутренняя сборка run/read/cancel проверена](mcp-diagnostic-storage-host-acceptance-v1.json): реальный MCP runtime с подтверждением человека выполнил диагностический запрос к HTTP127, сохранил отдельный encrypted payload и выдал модели только квитанцию. История доступна новому read-only токену с исходной привязкой. Отмена уже отправленного второго запроса закрыла socket; два cancel дали один audit и quarantine без результата, повторной отправки не произошло. Проверены короткие SQL/cleanup budgets и срок private prepared plan для длинных измерений. Исторические SQL snapshots теперь используют bounded connector. Все относящиеся проверки прошли; source factory не регистрирует публичные tools/routes. Production-переход схемы v2 и публичное подключение пока не выполнены. + +[Диагностические SQL-компоненты проверены](mcp-diagnostic-storage-connection-source-acceptance-v1.json): источник и назначение можно читать через backend, принадлежащий storage host; native/crypto implementations остаются штатными. Отдельный connector соблюдает отмену и сроки ожидания соединения/запроса, уничтожает неопределённое соединение и допускает независимую запись карантина через новый lease. Пройдены 24 новых unit tests, 6 driver SQL-сценариев, 22 journal SQL-сценария, production prepare/preflight на одном backend, dump/restore/tamper и полный management profile. Это opt-in компоненты; общий runtime host и публичное подключение v2 ещё открыты. + +[Исправлена запись карантина неопределённой диагностики](mcp-diagnostic-quarantine-cleanup-acceptance-v1.json): потеря подтверждения уже сохранённого результата больше не позволяет обойти карантин из-за отмены исходного запроса, его deadline или смены настроек. Отдельный ограниченный cleanup сохраняет точную scope/identity-привязку и не создаёт новых попыток или результатов. Пройдены 11 model и 22 SQL-сценария, dump/restore/tamper, диагностическая регрессия, build/types/lint. Это opt-in v2 source; public registration и production enrollment ещё не завершены. + +[Экспорт существующих IPsec-сертификатов активирован после отдельного разрешения](mcp-existing-ipsec-export-active-acceptance-v1.json): общий guarded REST/MCP путь работает для client/server/other/CA. REST требует вход, network-write, явный private intent и UUID запроса; CA — отдельный флаг. MCP требует человека и отдаёт модели только квитанцию, файл владельцу доступен 15 минут. Старые export bodies/fallback удалены. Штатные assemblies прошли SQL/HTTP/portal/dump/restore/tamper, 5 API и 7 browser checks, MCP/IPsec/model profiles, build/type/lint. Покрытие: **354 инструмента; 193 mediated /62 excluded /7 pending из 262 REST routes**. Реального экспорта CHR и деплоя не было; native, routing, diagnostics и остальные пункты полного плана ещё открыты. + +[Принят разрешённый cancellation host](mcp-diagnostic-cancellation-host-acceptance-v1.json): одобренный патч применён, **17 настоящих SQL-сценариев**, профиль диагностики и dump/restore/tamper прошли. Текущие права, исходная операция, отмена, аудит и общая reservation проверяются одним backend; повтор не удваивает audit/quota. Проверены rollback, lost COMMIT ACK и гонки с claim/завершением. Новая защита сервера сохраняет право владельца остановить уже идущую работу; это не разрешение native-записи. API не зарегистрирован, рабочая БД и CHR не затронуты. + +[Сквозная приёмка IPsec export UI/REST/MCP/портала](mcp-existing-ipsec-export-integration-acceptance-v1.json): интерфейс запрашивает private/CA consent до POST, передаёт один UUID на подтверждённую попытку и отменяет pending fetch при закрытии. **5 API и 7 браузерных тестов**, 41 HTTP mapping model case и реальная SQL/HTTP-композиция с обоими selector и всеми четырьмя ролями через REST и MCP прошли. Проверены human approval, encrypted artifact, owner-only portal/listing/TTL, unknown/no-replay, revoke/cancel и настоящий dump/restore/tamper. Штатный registrar и REST переключение подготовлены за выключенным existing/CA flag, публичное покрытие ещё 353 /191/62/9. Приёмка использует только подставной RouterOS/PKCS12; реальный экспорт ключей и деплой не выполнялись. + +Получено отдельное разрешение на cancellation host: проверенный `tmp/mcp-diagnostic-cancel-host-reviewed.patch` применён. Регистрации API нет; его отдельная транзакционная приёмка выполняется только на временной PostgreSQL. + +[Общий executor IPsec-экспорта и закрытые REST/MCP assemblies](mcp-existing-ipsec-export-transport-acceptance-v1.json): native export/read/cleanup теперь выполняются одним алгоритмом. REST использует реального пользователя и opaque одноразовый token; MCP сохраняет совместимую sealed authority и выдаёт только квитанцию. Прошли **48 REST-модельных и 14 настоящих SQL-сценариев журнала**, включая конкуренцию, потерю COMMIT ACK, отзыв после реального ожидания lock и порядок checkpoint 1→2; dump/restore/tamper, полный MCP, build/type/lint тоже прошли. Портал распознаёт новый artifact kind; при закрытом feature gate запрос останавливается до шифротекста/native I/O. Действующие REST export handlers пока не переключены, новые MCP tools не зарегистрированы, флаг existing/CA export остаётся false. Полный план и 9 pending не закрыты. + +[Подготовлен закрытый адаптер экспорта существующих IPsec-сертификатов](mcp-existing-ipsec-export-adapter-acceptance-v1.json): оба REST selector, human-only план, sealed binding без самоссылки inputProof, строгая квитанция и read-only сверка с VERIFIED step/artifact проверены на синтетических зависимостях. Публичной регистрации и включения экспорта существующих/CA-ключей нет. Исправлено затирание приватных Buffer в reader, CREATE и закрытом executor при отказе, смене состояния и позднем ответе после отмены; 10 проверок очистки и профили RouterOS files/IPsec/pending foundations прошли. Полный план и 9 pending остаются открытыми. + +Связка реальных adapter/executor проверена с подставным I/O, включая inputProof на durable claim и запрет повторной отправки. Новый `test:mcp:ipsec-create:pg` прошёл SQL/HTTP CREATE и настоящий terminal restore/tamper с явным `MMAPP_PG_CLIENT_BIN`. В первом широком запуске все SQL-этапы прошли, но финальный restore не нашёл `pg_dump`; этот отказ сохранён отдельно, а успешный focused run не подменяет его код завершения. + +[Принято сравнение данных реального MCP-восстановления](mcp-terminal-restore-data-equivalence-acceptance-v1.json): на собственной временной PostgreSQL содержимое 97 таблиц и 8 sequences совпало с архивом после миграций, до запуска фоновых задач. После запуска отдельно проверены связь архива с владельцем/операцией и единственная точная запись аудита. Никакие таблицы из сравнения не исключались. Исправлены лишний пустой flush свежего/reset Traffic Flow engine и запуск выключенного коллектора при завершении операции БД. Прошли 13 SQL-сценариев inventory, terminal restore/tamper, SQL-регрессия Flow, полные system-database и Flow ownership profiles, 14 lifecycle-тестов и build. ESLint: 0 ошибок, одно существовавшее предупреждение. Проверка остаётся тестовым evidence; production READY, cancellation host, деплой и изменения CHR не включены. Полный план ещё не завершён. + +[Принят read-only data inventory](mcp-migration-data-inventory-acceptance-v1.json): 15 модельных и 13 SQL-сценариев, включая реальное равенство содержимого и sequence после `pg_dump/pg_restore`, native array bounds, дубликаты, лимиты и внешнюю `mmapp_control`. Данные хешируются потоково, сырые строки не выдаются; views не исполняются. Full system-database profile, build, lint и terminal restore/tamper прошли. Подписанный issuer/reference и quiescence ещё не реализованы, READY остаётся false. Отложенный cancellation host не применялся, рабочая БД и CHR не менялись. + +[Проверены SQL-предпосылки cancellation host](mcp-cancellation-source-prerequisites-acceptance-v1.json): 8 сценариев зашифрованного источника и текущих grants, включая терминальные `rejected`/`partial`, вместе с прежними 17 storage/GC-сценариями и настоящим dump/restore. Подготовленный host-патч прошёл статическую проверку и review, но пока не применён: автоматическая проверка потребовала отдельного разрешения на запись отмены/аудита через настроенный pool. Его runtime-композиция ещё не проверялась; регистрации API и деплоя нет. + +[Принят разрешённый GC диагностики](mcp-diagnostic-expiry-gc-acceptance-v1.json): 17 SQL-сценариев удаления только истёкших служебных записей с защитным интервалом и текущим retention, плюс 12 сценариев reader истории и настоящий dump/restore/tamper. Свежий карантин и работающая операция блокируют удаление; rollback и потеря ACK проверены. Планы, операции и общий аудит сохраняются. Отмена использует одну reservation с выполнением, без двойного возврата квоты; повторная отмена больше не дублирует audit. Production-схема, worker и public registration ещё не включены; очистки действующей БД и изменений CHR не было. + +[Принят отдельный reader истории диагностики](mcp-probe-separated-history-acceptance-v1.json): 12 SQL-сценариев с исходным истёкшим MCP credential и новым текущим read-допуском, 42 исторических теста и 4 проверки deadline/abort. Reader схемы v2 сохраняет исходную identity и envelope, проверяет admission и раздельные proofs, различает expired/unavailable и повторно читает карантин/purge после расшифровки. Конкурентный отзыв credential блокирует выдачу. Полные MCP/diagnostics profiles, build, lint и настоящий dump/restore/tamper прошли. Подключение production-схемы v2 и регистрация диагностики остаются отдельными незавершёнными пунктами; деплоя и metadata GC не было. + +[Этап общего сетевого контекста, эталона схемы и хранилища диагностики](mcp-shared-context-reference-storage-acceptance-v1.json): приняты независимый подписанный эталон 36 миграций и текущая проверка схемы/начальных данных (8 SQL-сценариев), общий бюджет configuration bridge и native snapshots (15 HTTP-тестов, 14 SQL/HTTP-сценариев, 126 запросов без native writes), а также опциональный storage adapter журнала (7 SQL-сценариев). Полный scoped prepare/approval/apply с genuine CONFIG_APPLIED, настоящий dump/restore/tamper, профили MCP/system-database/диагностики/native readers, build и scoped lint прошли. Production DDL, metadata GC, READY и автоматический сетевой inverse не включены. Полный план и 9 pending остаются открытыми. + +[Новый этап controlled migrations и genuine configuration bridge](mcp-controlled-dispatch-configuration-bridge-acceptance-v1.json): **8 настоящих SQL-сценариев в 7 отдельных БД**, два полных исполнения 36 закреплённых файлов и настоящий dump/restore/tamper прошли. Fixed dispatcher сохраняет intent до SQL, использует exclusive session lease на том же application backend, не переписывает self-managed файлы, проверяет COMMIT command tag и markers, затем сохраняет purpose-separated HMAC квитанцию. Потерянный ACK application COMMIT запрещает повтор; если потерян только ACK публикации уже подтверждённой HMAC-квитанции, новая invocation проверяет prefix и продолжает со следующего файла. Подмена hash одновременно в singleton/history не проходит. Это опциональный внутренний dispatcher: default startup не изменён, READY и schema/data verification не выдаются; актуальная полная сверка схемы/данных и quiescence всех writers перед activation остаются открыты. + +Genuine configuration bridge проверен в полном scoped SQL/HTTP-профиле через prepare/approval/apply: revision-3 persistent manual request и реальный CONFIG_APPLIED, текущий raw owned rule/dependencies, одноразовый WeakMap context и повторный read-back на consume. JSON-копия, close, отключение профиля и подмена checkpoint блокируются без новой native-записи; autoRecovery guard сохранён. **30 bridge и 10 dispatcher модельных тестов**, native профиль 210 тестов, system-database профиль, backend build и scoped ESLint прошли. Bridge пока использует отдельный ограниченный context reader: общий network budget и per-round full proof этим не доказаны, assembly и automatic inverse не подключены. + +[Дополнительное read-only наблюдение CHR](mcp-chr-ssh-forwarding-policy-observation-v1.json) подтвердило forwarding-enabled=no и www port 80 на первом CHR. Второй CHR отклонил новый SSH-вход до чтения настроек; повторов и изменений конфигурации не было. Полная recursive/forwarding-приёмка остаётся открытой. Деплоя, production DDL и новых публичных инструментов нет; полный план и 9 pending не закрыты. + +[Новый этап durable schema control, archive boundary и native assembly](mcp-control-archive-native-assembly-acceptance-v1.json): на временной PostgreSQL прошли **14 сценариев в 9 отдельных БД**, включая конкурирующих владельцев, три потери ACK реального COMMIT, restart quarantine, подмену истории, сохранение control вне public, настоящий pg_dump/restore и отказ архиву с control objects. REST и MCP export теперь исключают mmapp_control; общий restore проверяет TOC до SQL decode и target replacement. Это защита пространства управления, а не проверка произвольного SQL внутри архива. Production control tables/startup/dispatcher ещё не подключены; READY не выдаётся из caller JSON или равенства схемы. + +Native assembly получила **23 теста** и проверена на фактических baseline/двух forward/двух inverse снимках SQL/HTTP-reader: exact scope/source/phase/time и общий бюджет, strict parser для каждого этапа, явный UNKNOWN при отсутствии genuine configuration proof и log interval. Профиль native models теперь содержит **180 тестов**; ещё 20 control, 3 archive boundary и 4 acquisition CLI теста прошли. Общие MCP/system-database профили, backend build и scoped lint прошли; все backend-профили не повторялись. + +[Read-only попытка сбора native recursive evidence с CHR](mcp-chr-recursive-acquisition-attempt-v1.json): SSH к стенду и обоим CHR подтверждён по прежним pinned host keys; оба первых REST GET через временные двойные SSH-туннели завершились без response-body, без повторов и изменений конфигурации. Причина не установлена, raw route/nexthop samples не получены. [Ограниченный протокол сбора](mcp-recursive-native-acquisition-contract.md) и CLI готовы; undocumented numeric joins остаются unknown. Это не приёмка recursive convergence или трёхузлового forwarding. Деплоя и production DDL не было; полный план и 9 pending остаются открытыми. + +[Новый этап native readers и проверки схемы](mcp-native-readers-schema-inventory-acceptance-v1.json): **154 модельных теста**, **14 SQL/HTTP-сценариев (99 запросов, 0 native writes)** и **17 SQL-сценариев schema inventory** прошли на изолированном стенде. Обе SQL-приёмки включают настоящий dump/restore и отказ для подменённого архива. Настоящий loopback HTTPS проверил DNS pin, сохранение Host/SNI, доверенную CA, отказ при неверном имени и недоверенной CA. Полный MCP-профиль, существующие server connection tests, backend build и scoped ESLint прошли. + +Reader закрепляет текущие полномочия и endpoint, использует один транспорт и общий абсолютный бюджет ≤120 секунд: 1 MiB/ответ, 8 MiB суммарно, 256 запросов, 1000 строк/ответ и 10000 суммарно. Baseline и по 2–3 раунда forward/inverse не сбрасывают лимиты. Подтверждённый ping 3/0 не запускает traceroute. Парсер принимает точный native BGP session ID и прямой next-hop; recursive next-hop и полнота log interval остаются unknown. Это внутренний read-only слой с securityProof:false: подписанный полный checkpoint и автоматический inverse не включены. Process-local запрет повторного reader имеет предел 1024 source operations и не заменяет durable runner admission после рестарта. + +Новый PostgreSQL 18 comparator сравнивает логическую схему независимо мигрированных БД, отдельно сохраняет и проверяет physical fast-default metadata, отказывается при неподдерживаемых объектах и внешних зависимостях. Реальная SQL-проверка обнаружила и закрыла пропуск зависимости view через pg_rewrite. Равенство DDL не доказывает эквивалентность данных и не разрешает enrollment. Durable startup recovery/control namespace и подключение comparator к dispatcher остаются открыты. CHR, production БД и деплой на этом этапе не затронуты; 9 pending не изменились. + +Актуальный реестр: **353 инструмента; 191 mediated / 62 excluded / 9 pending из 262 REST-маршрутов**. Полный план остаётся открытым. Проверки, хеши исходников и границы приёмки: [протокол сетевого управления](mcp-network-management-acceptance-v1.json). + +[Предыдущий протокол common poll и reservation store](mcp-poll-reservation-acceptance-v1.json): полный private poll assembly прошёл actual SQL/loopback HTTP — 4 GET, 148 maintenance effects всех 12 parents, 152 публикации и настоящий dump/restore/tamper. Одна фабрика связывает journal, production IO и maintenance lease с одним SQL backend; writer останавливается до relation SQL, повтор не отправляет GET, подмена input/подписи и отзыв блокируются. Предельные 120 секунд и полный maintenance сохранены; решения допуска не кэшируются. Отдельная приёмка выполнила 28 настоящих collector/FK-сценариев с 8 lock waits, 8 `55P03` и 8 потерянными ACK реального COMMIT. Это внутренние entrypoints, не приёмка публичной HTTP-аутентификации или всех ручных MCP collectors. + +Private reservation proposal/store прошёл 10 SQL-сценариев и dump/restore/tamper: 2 mutation units + 2 profile slots выделяются атомарно; manual/reserved inverse используют один source-step PK. Учитывается legacy switch history; capability для forward выдаётся только после нового подтверждённого COMMIT, read/repeated reserve/restart/JSON её не восстанавливают. После lost ACK capacity может остаться held без права продолжения; durable SQL unknown marker не заявляется. Refund и полный reserved inverse lifecycle ещё не реализованы. Runtime должен подключить store на том же transaction owner без двойного debit и согласовать controller accounting. Full poll HTTP/MCP routes, production migrations/triggers и controller пока не активированы; native/CHR/forwarding не заявляются. MCP-модели, адресные профили, build и scoped lint прошли. Счётчик pending прежний; предыдущие снимки ниже не заменяют этот протокол. + +[Предыдущий этап writer/routing integration](mcp-writer-routing-integration-acceptance-v1.json): shared admission до SQL на том же backend подключён к 17 инвентаризированным группам записи, включая Drizzle/flow, collectors, FK cascade, partition DDL, миграции/import/psql restore и legacy CLI. CLI использует общий атомарный rebuild с квитанцией и ожиданием существующих writers. Отказ БД не создаёт ложное offline/down измерение; 27 проверок реального кода collectors прошли. Отдельно остаётся SQL-приёмка каждого production entrypoint и сборка full poll с одним backend для maintenance lease и всех его транзакций. + +Постоянные routing profile ID/version теперь передаются доверенным внутренним контекстом; scoped request v3/checkpoint v2 сохраняют исходные полномочия. Forward fences проверяют их после durable intent, а ручной inverse захватывает их на всю сессию и ограничен исходным validUntil. Проверены actual SQL writer12/CLI3, scoped persistent11/metadata7, базовые 133/8/17/10/40 и настоящий dump/restore/tamper; финальная inverse-fence дельта отдельно прошла 3 поздних изменения с нулём PATCH/HTTP. Существующая CLI SQL-регрессия, полный MCP, IPsec/module-completion, адресные модели, build/lint прошли. Новых маршрутов, production-миграции, включения controller/reserved inverse, деплоя и обращений к CHR нет; счётчик pending прежний. Полный inverse lifecycle после финальной узкой дельты не повторялся. Ниже сохранены предыдущие снимки; их границы уточняет текущий протокол. + +[Новый протокол runner/routing authority](mcp-runner-routing-authority-acceptance-v1.json): все шесть диагностик проверены через actual dispatcher/approval/apply/leased runner, с полными страницами и заменой credential на observer read-only. Отмена до execute имеет подтверждённый ноль этапов и rejected/cancelled без needsReview; отмена во время запроса сохраняет unknown/quarantine без повтора. Во временной PostgreSQL прошли 8 runner, 17 persistent-authority, 40 poll-journal и 10 lease-сценариев вместе с базовыми 133 SQL и настоящим dump/restore/tamper. Routing validator ещё требует подключения к controller/checkpoints; новые production-маршруты и схемы poll не активированы. + +Для полного server poll подтверждён недостаток row-trigger-only барьера: relation lock уже взят до ожидания advisory lock. В SQL воспроизведён безопасный отказ 55P03 без DROP; early admission до SQL проходит. Аудит охватил 12 partition parents и 17 writer-групп, включая ORM и FK cascade; их общий допуск на том же backend ещё нужно подключить. Snapshot-only сценарий не заменяет полную операцию poll. + +[SQL journal 0035](mcp-server-diagnostics-journal-acceptance-v1.json) дополнен [проверенным слоем общих adapters и отмены](mcp-server-diagnostics-adapters-acceptance-v1.json): **133 реальных SQL-сценария**, настоящий dump/restore/tamper и loopback HTTP всех шести REST-диагностик через общий executor с синтетическими provider-ответами. Сохранены REST JWT/local identities, scoped BW fallback и текстовый контракт DNS CNAME/MTU. MCP adapter закрепляет реальный operation/step и private binding; историческое чтение авторизует текущий read credential без замены исходной подписанной identity. Миграция 0036 хранит запросы отмены, runtime передаёт opt-in AbortSignal и отделяет diagnosticProgress от configuration writes. Модельные проверки, полный MCP/IPsec/module-completion, build и scoped ESLint прошли. Полные публичные результаты до 128 KiB подготовлены для выдачи страницами с SHA-256. + +`/probes/run` пока не переключён на этот слой: нужны production-подключение storage admission/quota/retention, production key provisioning, регистрация MCP run/read/cancel и HTTP transport-приёмка зарегистрированных маршрутов. Пользователь разрешил GC; его адаптированный патч и изолированная SQL-приёмка завершены в новом этапе в начале документа. Очистки действующей БД не было. Реестр и pending не изменились; production-схема не применялась. + +[Новый протокол runtime admission и восстановления](mcp-reservation-runtime-bootstrap-acceptance-v1.json): на отдельной PostgreSQL прошли **15 сценариев** атомарного admission через `applyPlan` — операция, 2 mutation units, 2 profile slots и общий source-step slot записываются на одном backend без двойного debit. Повторное применение и lost COMMIT ACK не выдают capability продолжения. Полный manual inverse SQL/HTTP и dump/restore/tamper прошли; исправлена найденная регрессия подписи: отсутствующие optional reserved-поля больше не меняют fingerprint ручной квитанции. Reserved engine использует тот же 0031 PK и общий исполнитель; положительный autoRecovery lifecycle и его запуск остаются закрыты context guard. Отдельная повторная IPsec delete/pool-приёмка также прошла. + +Добавлена **инертная модель полного сетевого verifier: 46 тестов**. Она различает installation table и probe VRF, требует точные route/next-hop/BGP/BFD/probe/log identities, две стабильные полные выборки, полноту log interval и общий бюджет ≤120 секунд между forward/inverse. Любая неопределённость преобладает над failure. Это классификация переданных наблюдений, не native reader, подписанный checkpoint, authority или разрешение на откат; per-response wire/page limits ещё должен обеспечивать native adapter. + +Для restore введена общая process-local защёлка: после начала target psql NetFlow/планировщик/RIPE не запускаются до успешных миграций, включая последующий export в том же процессе. Шесть тестов реальных entrypoints с инертным I/O, независимые 15 адресных проверок и настоящий owned terminal restore прошли. **Durable startup recovery не реализован.** Подготовлен точный манифест 36 SQL-файлов (14 сценариев); dispatcher, независимый schema comparator и control namespace ещё не подключены. Финальные MCP-профиль, модели, backend build и lint прошли; все backend-профили не повторялись. Деплоя, production DDL и обращений к CHR не было. + +Ниже сохранены предыдущие этапы; их открытые пункты нужно читать с учётом нового протокола. Общий план остаётся открытым, реестр и 9 pending не изменились. + +- [x] **IPsec init v3**: общий REST/MCP executor всех полей мастера, подпись только новых ACK-owned сертификатов, source/authority fences, полный raw preflight и обязательная история. Изменение существующего IPv4-пула учитывает DHCP, PPP, Hotspot и next-pool; внешние потребители блокируют план. +- [x] **IPsec revision restore v1**: строгие schema/fingerprint/xmin, точные записанные поля и empty clears, сохранение PSK включая пробелы, проверка PKI/profile/proposal/group/NAT/pool зависимостей и отсутствие replay. Незаданные native-поля существующего mode-config сохраняются. Если старый snapshot не позволяет восстановить отсутствующую policy или mode-config, legacy certificate-поле либо unknown auth capability, операция отказывается до записи; утраченные ключи не генерируются заново. Непустой native default для записанного пустого значения не принимается как успешное восстановление. +- [x] **WireGuard revision restore v1**: полная форма интерфейсов/peers/IPv4/IPv6, encrypted desired keys, явный inventory удалений, staged disable/enable, private per-command proof/read-back и обязательная история. Ключи в модель не выдаются; сверка использует только точный сохранённый VERIFIED checkpoint. +- [x] **IPsec server delete v2**: после отдельного разрешения применён шестифайловый патч предохранителей IPv4-пулов. Сценарий удаления не расширен: внешние DHCP/PPP/Hotspot/next-pool потребители запрещают удаление, старые планы v1 не выполняются. Внешняя RADIUS Framed-Pool authority не выводится из native-конфигурации. +- [x] **VRF production preparation**: явный productionAdmission:true на существующих typed tools сохраняет прежнюю подготовку простых планов без флага. Encrypted ticket сохраняется вместе с реальным планом в одной транзакции; ключ выводится из durable private material с отдельным purpose. Admission/private domains v2 исключают только скалярную telemetry соответствующего каталога; неизвестные и вложенные поля остаются sealed. 86 моделей отказа, 7 runtime SQL-сценариев и 11 SQL-контрактов tickets пройдены. Unknown claim не перезаписывает завершённую операцию. +- [x] **VRF transition model**: 62 проверки шести lifecycle-фаз и произвольных IPv4 /23, IPv6 /56. Это чистый classifier для доказанных bridge effects; reenable/LL revival требуют непрерывного checkpoint, disable ограничен наблюдавшимся циклом. Production executor и native companion admission не включены. +- [x] **Мониторинг частичного результата**: bounded nativeProgress отдельно показывает ACK и подтверждённые read-back команды; счётчик завершённых service steps сохранён отдельно. Private error payload не публикуется. + +SQL/HTTP использует временный PostgreSQL и локальные RouterOS fixtures. Новый fixture agent переиспользует максимум два соединения только с точным связанным loopback endpoint, сохраняет настоящий HTTP/parser/guards и не повторяет запросы; teardown восстанавливает transport. Это устраняет подтверждённый Windows EADDRINUSE в накопительном тесте. Production HTTP agent не изменён; его нагрузочная и живая CHR-приёмка этим протоколом не заявляются. Сборка, профили MCP/IPsec/WireGuard, новые модели, scoped ESLint и настоящий dump/restore/tamper проверены в указанном отчёте; все 27 backend-профилей не повторялись. Деплоя, live collector или изменений CHR не было. + +Остаются **9 REST-сценариев**: два экспорта существующих IPsec-сертификатов и семь ручных collectors (server/API ping/probes, traffic, uptime/group/speed-test). Persistent routing authority/reserved inverse/controller, production VRF executor/recovery/forwarding, внешние AI-клиенты, live portal sender/UI и независимая трёхузловая приёмка остаются в полном плане. Доступны только два CHR. + +Основы server poll, всех шести server probes и экспорта существующих IPsec-сертификатов пока не зарегистрированы. SQL journal probes отдельно принят на временной БД; подключение полного REST/MCP пути ещё не проверено. Production poll adapter прошёл инертные проверки и требует настоящего журнала и global writer lease. Production export и выдача его файлов выключены; разрешение CREATE клиента не распространяется на существующие ключи или ключ CA. Счётчик pending сохраняется. + +## Исторические этапы + +Прежние счётчики и ограничения ниже относятся к соответствующим снимкам; текущий реестр и протокол приведены выше. + + +Актуальный итог этого этапа: [объединённый протокол](mcp-modules-consolidated-acceptance-v1.json), **350 инструментов; 188 mediated / 62 excluded / 12 pending из 262 REST-маршрутов**. Полный план остаётся открытым. + +[NetFlow принят с финальными поправками](mcp-netflow-settings-acceptance-v2.json): `netflow.settings.update` **v3**, полный `{settings: …}` из десяти полей, резерв **4 публикации** при двух фазах. SQL подтвердил отказ старого v2 и бюджета 3 до записи. До ACK нет timer/packet/enrichment и автоматического повтора startup; normal runtime после ACK отделён от UDP readiness. [IPsec CREATE принят с точным bounded reader](mcp-ipsec-user-create-acceptance-v2.json): приватный PKCS12 читается по проверенному native ID без поиска нового файла по имени; поток, размер, общее время и допуск ограничены. v1 этих двух отчётов сохраняется как исторический снимок, текущие ограничения и поправки описаны в v2. + +Основная MCP SQL/HTTP-регрессия завершилась с кодом 0 вместе с настоящим dump/restore/tamper на baseline до последних двух поправок. Финальные дельты отдельно прошли свежие целевые SQL/HTTP, весь MCP model profile, NetFlow settings/file profiles, backend build и ESLint. Это не повтор всех 27 backend-профилей и не живая CHR/UDP-приёмка. VRF reader/admission/tickets прошли 76 моделей и 11 SQL-сценариев; native execution пока выключен. Деплоя и новых обращений к CHR не было. + + +Текущий подтверждённый срез: **350 инструментов; 188 mediated / 62 excluded / 12 pending из 262 REST-маршрутов**. Полный план открыт. Ниже сохранены исторические этапы; их прежние счётчики и ограничения относятся к указанным снимкам. + +- [x] [Полные настройки NetFlow и ключи](mcp-netflow-settings-acceptance-v1.json): общий REST/MCP executor всех десяти полей, включая no-op/display-only restart; full-config CAS, постоянные phase intents и внутренние main/worker lifecycle checkpoints. Старый handler из четырёх полей заменён общим executor; актуальная версия v3 и бюджет 4 описаны выше (промежуточный v2 исторический). `mm_netflow_host_keys_generate_plan` создаёт только неполную пару и выдаёт публичный ключ. SQL/HTTP проверяет настоящий disabled main flush; worker startup/shutdown проверены инертными моделями. Живой UDP worker отдельно не принимался. +- [x] [CREATE клиента IPsec](mcp-ipsec-user-create-acceptance-v1.json): PSK/certificate/static IP, полный private proof и dependency closure, подпись/export/cleanup без повторов, обязательная история. После отдельного разрешения пользователя PKCS12 выдаётся владельцу с network-write через зашифрованный портал на 15 минут только при `privateKeyExport=true` и human approval; модель получает квитанцию. Loopback SQL/HTTP всех веток и owner download прошли; настоящие CHR/SA/forwarding не проверялись. +- [x] [Production VRF raw reader и durable tickets](mcp-vrf-production-ticket-acceptance-v1.json): 17 raw-каталогов и конкретные parent/peer/VTEP зависимости, HMAC admission, реальные encrypted plan bindings, отдельное соединение для одноразового claim. 76 моделей отказа и 11 SQL-сценариев прошли. Это precondition, **native execution не включён**; runtime journal/key provisioning и production transition classifier остаются открытыми. + +Целевые SQL-приёмки, настоящий dump/restore/tamper, весь MCP model profile, IPsec/settings/purge/ownership profiles, backend build и адресный ESLint прошли. Основная SQL-регрессия baseline затем завершилась; финальные дельты и точные границы проверки приведены в объединённом протоколе выше. Все 27 backend-профилей повторно не заявляются. Деплоя, запуска живого collector и обращений к CHR в этом этапе не было. + +Оставшиеся REST-сценарии: IPsec certificate export, user certificate export, server init и revision restore; ручные servers/API-ping/probes/traffic/uptime/speed-test collectors; WireGuard revision restore. Persistent routing profile authority/reserved inverse/controller, production VRF native/forwarding, внешние AI-клиенты/portal sender/live UI и независимая трёхузловая приёмка остаются в исходном объёме; доступны два CHR. + + +[Удаление IPsec-сервера принято](mcp-ipsec-server-delete-acceptance-v1.json): `mm_ipsec_server_remove_plan` использует общий REST/MCP executor с полным ordered manifest, защитами foreign references и объявленными CA cascade. Profiles/proposals и чужие объекты сохраняются; каждый DELETE проходит private stage proof, read-back и checkpoint, затем обязательную историю. Proof v2 исключает только certificate expires-after; даты, trust, CRL, ссылки и неизвестные поля сохранены. Старые планы блокируются повышенными версиями действий. SQL/HTTP, весь IPsec-профиль, весь MCP model profile, build, lint и настоящий dump/restore/tamper прошли. Реестр: **348 инструментов; 185 mediated / 62 excluded / 15 pending из 262 REST-маршрутов**. Настоящий CHR cascade/forwarding остаётся отдельной приёмкой; полный план открыт. + +`mm_ipsec_server_remove_plan` принимает `{serverId,removeCertificates}`; `removeCertificates` по умолчанию `true`, как в REST. Всегда нужны network-write, server/operation scope и подтверждение человеком. План показывает native-команды, все затрагиваемые объекты и косвенные удаления CA; budget включает каждый такой сертификат и историю. `false` сохраняет сертификаты. Пустой manifest записывает только историю. Старый REST `DELETE /api/ipsec/server/:serverId?removeCertificates=false` сохраняет `{ok:true}`; неподтверждённая история или команда возвращает ошибку без предложения повторить удаление. + + +Параллельно подготовлены [основы модулей NetFlow settings и production VRF](mcp-parallel-module-foundations-v1.json): 43 чистые модели executor и 65 моделей отказа admission соответственно. NetFlow ещё требует CAS/intents и внутренних collector fences; VRF — raw reader, durable ticket store, transition classifier и native executor. Runtime этих компонентов не подключён; это не закрывает ожидающие REST/VRF-сценарии. + +[Полный PATCH клиента IPsec принят](mcp-ipsec-user-update-acceptance-v1.json): `mm_ipsec_user_edit_plan` использует общий REST/MCP executor для имени, static IPv4/возврата к пулу, PSK, Remote ID и disabled, включая cert-only promotion/ignored fields, shared/foreign mode-config и историю. PSK в MCP передаётся только через привязанную к владельцу/серверу/клиенту ссылку. Каждый ACK проверяет точную сохранность полного private state; отзыв PSK-reference после первого ACK блокирует следующую команду. Исправлена ветка пустого staticIp: 400 до изменения имени. SQL/HTTP parity, весь IPsec-профиль, весь MCP model profile, build, lint и настоящий dump/restore/tamper прошли. Реестр: **347 инструментов; 184 mediated / 62 excluded / 16 pending из 262 REST-маршрутов**. Живые CHR и forwarding этим прогоном не проверялись; полный план остаётся открытым. + +`mm_ipsec_user_edit_plan` принимает `{serverId,clientId,patch}`. Используйте ID клиента из каталога (`identity:*ID` или `cert:*ID`). Поля patch: `name`, `staticIp` (IPv4 или `null` для пула), `remoteId`, `disabled`, `pskSecretRef`; пустой patch записывает только историю. Ссылка PSK создаётся владельцем в портале MCP с purpose `ipsec-user-psk::` для этого сервера. Сырой `psk` через MCP отклоняется. Сертификатный клиент без identity сохраняет прежнее поведение ignored fields, показанное в предупреждении плана. Общие mode-config и их потребители входят в зависимости плана. Автоматического обратного действия и повтора неизвестной команды нет. + + +[Native IPsec dependency guards приняты](mcp-ipsec-native-closure-acceptance-v1.json): `mm_ipsec_peer_create_plan` v4 закрепляет точный/default profile и существующие ссылки; `mm_certificate_update_plan` v3 учитывает цепочку сертификатов, identities и peers и требует одновременно network-write и certificates-write. Оба входа требуют human approval и полного private proof вместо усечённого generic snapshot. Смена подключения блокируется до первого GET, старые прямые native/inverse шаги — до SQL и native access. SQL/HTTP, прежние IPsec DELETE/PATCH, настоящий dump/restore/tamper, весь MCP model profile, build и lint прошли. Ожидаемый rename ссылок проверен на loopback; настоящий CHR rename остаётся открытой приёмкой. Реестр без изменений: **346 инструментов; 183 mediated / 62 excluded / 17 pending**. + +[REST/MCP rebuild NetFlow принят](mcp-flow-rebuild-adapter-acceptance-v1.json): `mm_netflow_facts_rebuild_plan` требует human approval и полного global/server scope; `mm_netflow_facts_rebuild_reconcile` сверяет историческую квитанцию без повтора записи. На временном PostgreSQL прошли actual main-writer drain, атомарные факты/событие/квитанция, scope/protection guards, новый credential и JWT REST parity. Backend build, весь MCP model profile и scoped lint прошли. Реестр: **346 инструментов; 183 mediated / 62 excluded / 17 pending из 262 REST-маршрутов**. Живой worker отдельно не запускался, деплоя не было; полный план остаётся открытым. + +`mm_netflow_facts_rebuild_plan` принимает строго `{}`. Право — `mm:traffic:write`; необходимы global scope и все серверы текущего каталога. Даже autonomous mode требует подтверждения человеком. Модель получает только `{ok,buckets,facts,days,jobId}`; private source/classifier proofs и зашифрованная привязка скрыты. `mm_netflow_facts_rebuild_reconcile` принимает `{operationId}` исходной операции того же подключения и требует актуального допуска к обеим операциям и всем исходным серверам. Отсутствие квитанции не разрешает повтор. Старый REST `POST /api/traffic/flow/rebuild-facts` возвращает прежние четыре поля без jobId; поздняя потеря допуска после публикации даёт unknown, а не предложение повторить запрос. + + +[Flow engine wiring принят](mcp-flow-engine-wiring-acceptance-v1.json): после отдельного разрешения применён цельный патч engine/worker/purge. Backend build, native-conservation SQL/HTTP, весь traffic-flow профиль, SQL logs и настоящий dump/restore/tamper прошли. Общий rebuild проверен на настоящих partitioned таблицах с явной no-op drain-фикстурой: дневная агрегация, rollback обоих кубов, поздние защиты/отзыв и чтение известной квитанции до drain. REST/MCP принят следующим этапом выше; проверка живого существующего worker остаётся открытой; деплоя и запуска collectors не было. + +[Native VRF v5 принят](mcp-chr-vrf-companion-native-v5-acceptance.json): отдельный новый fixture подтвердил create/rename/disable/re-enable/member transfer/delete, 18 ACK с read-back и штатную очистку. Независимый GET без исключений подтвердил ноль тестовых объектов и все 34 исходных хеша 17 каталогов; SSH закрыт. Base-контракт содержит 218 моделей отказа. Generic production MCP companion admission и forwarding этим прогоном не проверены и остаются открытыми. + +Отдельный [SQL-контракт flow writer](mcp-flow-write-transaction-pg-acceptance-v3.json) прошёл на временном PostgreSQL: атомарность двух shadow-кубов, события и квитанции, отсутствие повторного тела, lost COMMIT ACK, реальный advisory-lock barrier и отказ по отзыву до COMMIT. Это отдельный writer-контракт; последующая REST/MCP-приёмка приведена выше. `npm --prefix backend run test:mcp:vrf-cleanup` теперь запускает все три cleanup-контракта (50 + 53 + 94 модели отказа); весь профиль прошёл. + +[OSPF listing order принят](mcp-ospf-template-order-acceptance-v1.json): snapshots v3, optimizer v9 и template C/U/D v5/v4/v3 прошли main MCP SQL/HTTP, OSPF-профиль, SQL logs, настоящий dump/restore/tamper и весь MCP model profile. Порядок строк сохраняется; drift до PATCH отклоняется, после ACK остаётся unknown без VERIFIED. Общий flow rebuild executor ещё в работе. [Native VRF v4](mcp-chr-vrf-companion-native-v4.json) подтвердил create/rename/disable/re-enable, но остановился после ACK transfer на переназначении собственных kernel route IDs и отсутствии одного LL/LL-route. [Отдельная очистка завершена](mcp-chr-vrf-companion-transfer-cleanup-result-v1.json): все семь созданных объектов удалены с ACK и read-back, без повторов команд; независимая заключительная GET-проверка подтвердила ноль тестовых объектов и все 34 исходных хеша без исключений. Прогон v4 не подтвердил полный lifecycle; production MCP companion admission остаётся открытым. + +Предыдущая зафиксированная дельта: [снимок 383 файлов](mcp-vrf-accumulator-regression-v1.json) прошёл native-conservation SQL/HTTP, профиль traffic-flow, SQL logs, настоящий dump/restore/tamper, весь MCP model profile и backend build. Instance-based fact accumulator выделен без изменения REST/rebuild; атомарный MCP rebuild остаётся в работе. [Native VRF v3](mcp-chr-vrf-companion-native-v3.json) подтвердил create/rename, но остановился после ACK disable: RouterOS удалил dynamic table, сохранил выключенный virtual interface и вернул адреса/маршруты в main. Re-enable/transfer не запускались. [Отдельная очистка завершена](mcp-chr-vrf-companion-disabled-cleanup-result-v1.json): все семь ACK-owned объектов удалены без повторов команд, независимая GET-проверка подтвердила исходные 34 хеша и отсутствие тестовых объектов. Полный lifecycle не принят, остановленные команды не повторяются. + +Этап в работе: общий native verifier проверяет назначенный CREATE ID, состав и неизменность соседних объектов в видимой проекции; сохранён порядок 11 каталогов. Source-bound snapshots v2 и expected-prefix checks защищают подготовку, каждый шаг и dispatch после durable native intent. Целевая SQL/HTTP-приёмка, 54 HTTP-модели, ECMP+hash/singleton inverse и dump/restore прошли. OSPF builder исправлен со свежим source-bound capture (v8); SQL/HTTP подтвердил полный owner graph и VRF context, [измерив 93 HTTP-запроса](mcp-ospf-read-budget-observation.json) на один PATCH. Web CREATE adapter исправлен: CREATE не передаёт id даже как undefined, строгая схема сохранена. [Повторная объединённая SQL-регрессия снимка 379 файлов](mcp-native-conservation-regression-v1.json) завершилась с кодом 0: main integration, профиль OSPF, SQL logs, настоящий pg_dump/restore и tamper refusal. Весь MCP model profile и backend build также прошли. Все 27 backend-профилей этой дельты не повторялись. + +- [x] Вложенный polling OSPF устранён: [статический аудит](mcp-ospf-read-amplification-audit.json) вывел 1232 GET на один cost PATCH в трёхузловой HTTP-фикстуре из-за вложенного полного graph guard. Это не native timing measurement. Исправление внесено: grant/source перед каждым чтением сохранены; полный граф проверяется на пяти границах, последним перед dispatch и после ACK до VERIFIED. Модели source ABA/revocation и post-ACK classification прошли; успешная объединённая SQL-регрессия подтвердила 80 GET + 12 read-only POST + 1 PATCH = 93 HTTP-запроса вместо 1232 GET. Это трёхузловая HTTP-фикстура, не native forwarding-приёмка. + +- [ ] Общий этап пока не принят: VRF требует typed companion-контрактов для автоматически перемещаемых/удаляемых connected routes, таблиц и virtual interfaces. [Native-v2](mcp-chr-vrf-companion-native-v2.json) на RouterOS 7.24.5 подтвердил создание двух bridge, четырёх адресов и VRF, затем остановился на неизвестных kernel companions; rename/toggle/transfer не выполнялись. [Отдельная очистка завершена](mcp-chr-vrf-companion-cleanup-result-v1.json): все семь ACK-owned объектов удалены без повторов команд и без прямого удаления kernel-объектов. Первый cleanup остановился после ACK IPv4 DELETE из-за изменения собственного bridge status; GET-диагностика и отдельный guard точного наблюдённого перехода позволили проверить отсутствие удалённых ID и удалить оставшиеся три объекта. [Независимая заключительная GET-проверка](mcp-chr-vrf-companion-cleanup-final-verification-v1.json) подтвердила ноль тестовых объектов и совпадение всех 34 исходных хешей 17 каталогов уже без исключений. Исходный unknownWrite latch и отчёт не изменены. Полный native lifecycle и generic MCP companion admission остаются открытыми. + +Предыдущая принятая дельта: устранена подмена ID в native CREATE. Все 30 CREATE-схем запрещают `id`; старые некорректные шаги блокируются до отправки, проверка использует только acknowledged `.id`, inverse выбирает единственную добавленную строку. CREATE-версии повышены, recovery — v4. [SQL/HTTP-протокол](mcp-native-create-id-acceptance.json): код 0, нормальный create/inverse сохраняет существующий объект, legacy poison и старые версии блокируются, ignored/lost не повторяются. Весь профиль MCP, build и ESLint прошли; целевая recovery/maintenance SQL-регрессия на v4, включая primary-down alternate, и настоящий dump/restore/tamper завершились с кодом 0. Независимое read-only review не нашло ошибок в этой дельте. Полные 27 профилей после этой дельты не повторялись; принятый предыдущий прогон указан ниже. Реестр остаётся **344 инструмента; 182 mediated / 62 excluded / 18 pending**. + +- [ ] Усилить общий verifier: новый assigned ID должен отсутствовать before, изменения должны сохранять соседние объекты и порядок правил. Добавить expected-prefix proof между шагами и полные private/source/dependency contracts. Эта дельта не доказывает whole-catalog conservation. [Статический аудит до исправления ID](mcp-native-write-hardening-audit.json). + +Предыдущий этап, реестр: **344 инструмента; 182 mediated / 62 excluded / 18 pending из 262 REST-маршрутов**. [SLA и два IPsec-адаптера](mcp-routing-sla-ipsec-focused-acceptance.json) прошли целевую SQL/HTTP-приёмку с кодом 0 и настоящим dump/restore/tamper. Первый полный прогон остановился на дубликате разрешения в ACME-фикстуре (код 1); дубликаты исправлены только в двух тестовых фикстурах. [Повторная полная регрессия нового снимка 360 файлов](mcp-routing-sla-ipsec-acceptance.json) завершилась с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper прошли. UI не менялся, реальные CHR в этой серии не менялись; весь план остаётся открытым. + +`mm_route_optimizer_plan` v6 требует свежие SLA и preflight перед PATCH, включая fence после durable intent. После записи устаревший или плохой SLA не мешает сохранить точное CONFIG_APPLIED; контрольная проба проверяется отдельно. Это подтверждение конфигурации и контрольной пробы, а не пересылки трафика или нового SLA. Rights/policy/source/protection и immutable preflight identity остаются обязательными после записи. Ручной recovery v3 повторно проверен; автоматический scoped controller/reserved recovery остаются выключенными. + +`mm_ipsec_certificate_remove_plan` (`network.ipsec.certificates.delete`) принимает `serverId`, `name`, `force=false`. Он удаляет один выбранный native сертификат, включая foreign; force разрешает разрыв peer/identity references, но не CA cascade или CRL bypass. `mm_ipsec_peer_edit_plan` (`network.ipsec.peers.update`) принимает `serverId`, `peerId` и `patch`: name/address/exchangeMode/passive/profile/disabled, а также comment для business MCP/native. REST сохраняет прежнюю схему без comment. Пустые строки передаются, same-value отправляет PATCH, пустой patch сохраняет только историю. Certificate presence запрещена. Native rename/reference projection пока проверена только на loopback, её несовпадение даёт partial без повтора. + +Native `mm_certificate_delete_plan`, `mm_ipsec_peer_delete_plan`, `mm_ipsec_peer_update_plan` v2 используют те же guards и требуют отдельного business operation scope. Для native certificate DELETE нужны одновременно certificates-write и network-write. Все новые сценарии требуют human approval, exact private source/read-back, managed-only history и generic VERIFIED. Потеря ответа не разрешает replay. Известные секреты, отражённые в названиях зависимостей, маскируются. + +Предыдущий этап: **342 инструмента; 180 mediated / 62 excluded / 20 pending из 262 REST-маршрутов**. Разрешённый пользователем WireGuard export подключён: [целевая SQL/HTTP-приёмка](mcp-wireguard-export-focused-acceptance.json) и [полная регрессия объединённого снимка](mcp-wireguard-export-full-acceptance.json) завершились с кодом 0, включая 27 backend-профилей, SQL logs и настоящий dump/restore/tamper. Последующие коррекции [REST validation/native duration](mcp-wireguard-export-contract-acceptance.json) приняты отдельной целевой SQL/HTTP-проверкой; полный прогон после этой дельты не повторялся. Полный план остаётся открытым. + +`mm_wireguard_export_plan` (`network.wireguard.export`) принимает `serverId`, `interfaceName`, `format` (`rsc`, `conf`, `peer-conf`), необязательный `peerId` (native или `serverId:*ID`) и `includePrivateKey` (по умолчанию false). Нужны server/operation scope и `mm:network:write`; портал также требует обычный доступ к разделу MCP (`mm:mcp:admin` или `mm:settings:admin`). Даже autonomous mode требует человеческого подтверждения. Модель получает только квитанцию. Зашифрованный файл доступен 15 минут через существующую кнопку «Скачать файл» в операции MCP только вошедшему владельцу с действующими правами, source/policy/protection proofs и exact VERIFIED checkpoint. Отзыв доступа во время чтения блокирует выдачу; HTTP использует private/no-store. Настоящие ключи в этой приёмке не использовались. + +REST `/api/wireguard/export` использует тот же reader/renderer и сохраняет JSON `{format, filename, content}`. Только явный `includePrivateKey=true` включает ключ интерфейса в rsc/conf; peer-conf не включает приватные ключи. Имена файлов нормализуются в безопасный набор символов. PSK и невоспроизводимые состояния возвращают явную ошибку: несколько/динамические/специальные адреса, disabled peers/interface в conf, неоднозначные и неполные native-каталоги. Экспорт не подтверждает готовность туннеля или маршрутизации. + +Предыдущий этап recovery/maintenance: целевая SQL/HTTP-приёмка завершена с кодом 0, включая настоящий dump/restore/tamper; его проверки прошли и в объединённой полной регрессии export. Реестр: **341 инструмент; 179 mediated / 62 excluded / 21 pending из 262 REST-маршрутов**. [Протокол](mcp-recovery-maintenance-focused-acceptance.json). `mm_route_optimizer_plan` v5 заранее закрепляет отдельный management transport, а `mm_operation_recovery_plan` v3 поддерживает ручной обратный план для exact scoped Community operation. Обратное действие требует человеческого подтверждения, подписанного CONFIG_APPLIED исходного переключения и свежей проверки исходного кандидата уже в изменённом контексте. Один исходный operation/step может занять только один durable inverse intent. Срок — не более 120 секунд от исходной операции с дополнительными ограничениями credentials, policy и канала; неизвестный исходный результат не разрешает inverse. Новые native ID, тела правил или credentials из ответа модели не принимаются. После read-back сохраняется RECOVERY_CONFIG_APPLIED, после контрольной пробы — RECOVERY_VERIFIED; read-only reconcile отдельно проверяет exact generic VERIFIED. Автоматический recovery и scoped controller остаются выключенными. + +Резервный transport закрепляется, пока primary доступен. При последующем отказе primary сверяется только его логическая SQL-идентичность и версия подключения, а сетевые чтения и обратный PATCH идут через закреплённый адрес с проверенной RouterOS license identity. Замена, отзыв, expiry канала или изменение подключения запрещают этот transport. Независимость физического пути остаётся человеческой аттестацией; loopback-приёмка не доказывает независимость реальных CHR. + +Локально принят также `mm_ipsec_user_remove_plan` (`network.ipsec.users.delete`): `serverId`, namespaced `clientId` вида `identity:*ID` или `cert:*ID`, `removeCertificate` по умолчанию true. Он сохраняет REST-семантику удаления клиента и необязательного безопасного удаления его сертификата/mode-config; CA, серверные сертификаты и общие зависимости не удаляются. Отдельные команды и история проверяются последовательно, unknown не повторяется. + +`mm_netflow_purge_plan` (`traffic.flow.purge`) требует полного global/server scope, traffic-write и человеческого подтверждения. План явно показывает необратимую очистку всех шести NetFlow-таблиц, остановку с подтверждением завершения worker, сохранение настроек/ключей/peers, штатный restart и общий retention maintenance. Последний может удалить показанные истёкшие разделы traffic, ping, uptime, snapshots и internet-path: каждый включён в объекты и бюджет плана, новый неутверждённый раздел не удаляется. Потерянный COMMIT не повторяется; новые flows после restart допустимы. Отправка уведомлений этим сценарием не запускается. + +На предыдущем этапе локально приняты [Route AI checkpoint и два адаптера](mcp-scoped-checkpoint-adapters-acceptance.json), с отдельным [focused-протоколом](mcp-scoped-checkpoint-adapters-focused-acceptance.json). Реестр — **339 инструментов; 177 mediated / 62 excluded / 23 pending из 262 REST-маршрутов**. Полная последовательная регрессия завершилась с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper. Focused SQL/HTTP, targeted models, build и ESLint также прошли. Scoped controller и автоматический inverse ещё не включены. + +`mm_server_test_connection_plan` / `servers.test-connection` проверяет новое назначение до сохранения сервера через два bounded GET. Все параметры REST, включая hostname, API path и TLS, поддержаны; DNS ограничен и адрес закрепляется с исходным Host/SNI. Нужны `mm:servers:write`, global/full-server scope и человеческое подтверждение. Вместо raw password передайте owner secret `passwordRef` без `serverId`, с purpose `server-connection-test:`; точное нормализованное значение строит `serverConnectionSecretPurpose`. Результат скрывает отражённый пароль. Unknown transport/parse/overflow не повторяется, reconcile не отправляет новые запросы. + +`mm_ipsec_peer_remove_plan` / `network.ipsec.peers.delete` принимает `serverId`, точный `peerId` и `force` (по умолчанию false). Force разрешает разрыв ссылок identities, сами identities сохраняются. Shared REST/MCP executor проверяет bounded полный private proof, сохраняет один DELETE и managed-only историю. Совпадение конфигурации после lost acknowledgement показывается отдельно: `matchesDesiredState` требует exact generic VERIFIED receipt и подтверждённую историю. После partial/unknown нет повтора или автоматического inverse. + +На предыдущем этапе `mm_route_optimizer_plan` имел версию 4, `mm_route_profile_save_plan` — 3. Старые планы требуют новой подготовки. Scoped PATCH теперь сохраняет encrypted `CONFIG_APPLIED` до post-probe и отдельно подписывает результат пробы; generic VERIFIED без обоих подтверждений недостаточен. Подтверждённый отказ пробы — partial, неизвестная проба — unknown. Profile CAS/per-profile lease защищают состояние от stale tick, но profile-version у native transport и scoped recovery engine ещё не реализованы. Scoped auto/controller guards сохранены; forwarding этими проверками не подтверждён. + +Регистрация защиты RouterOS-объекта с ID вида `*3` требует `serverId`: без него `POST /api/mcp/protections` возвращает 400 `INVALID_INPUT` до сохранения. ID локален для устройства; одинаковые ID на разных серверах независимы. Legacy-записи продолжают читаться. [Протокол](mcp-protection-scope-acceptance.json) подтверждает HTTP-валидацию и остановку REST/MCP import при поздней scoped-защите peer до первого PATCH, без автоматического повтора. Focused SQL/HTTP, настоящий dump/restore/tamper, build, JWT administration model и ESLint прошли; полные 27 профилей повторно на этой правке не запускались. + +Этап 24 октября — общий WireGuard import и scalar proofs — локально принят: [протокол и отпечатки исходников](mcp-wireguard-import-scalar-acceptance.json). Реестр: **337 инструментов; 175 mediated / 62 excluded / 25 pending из 262 REST-маршрутов**. Focused SQL/REST/MCP и полная последовательная регрессия завершились с кодом 0: 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper, build и ESLint прошли. UI не менялся; прежние результаты 16 браузерных сценариев, двух helper-тестов и frontend TypeScript унаследованы после сверки hashes исходников и логов. Новый live import/browser не заявляется. Полный план остаётся открытым. + +Для импорта владелец с правом `mm:mcp:admin` (или `mm:settings:admin`) создаёт защищённую ссылку через JWT API `POST /api/mcp/secrets`: purpose `wireguard-import-config`, нужный `serverId` и содержимое `.rsc`/`.conf` в `value`. Полученный `secretRef` передаётся инструментам как `contentRef`; исходный файл и ключи модели не передаются. `mm_wireguard_import_preview` проверяет формат без RouterOS polling (`nativeChecked: false`). `mm_wireguard_import_plan` после человеческого подтверждения проверяет актуальное устройство и применяет все объекты по стадиям. Обоим инструментам нужны явно выданные operation IDs, server scope и `mm:network:write`. Пределы — 256 КиБ, 100 peers, 100 allowed routes на peer. + +Все объекты сначала создаются отключёнными, затем отдельно включаются запрошенные peers/address/interface. REST и MCP используют один исполнитель. Preview скрывает ключи, а REST сохраняет совместимую форму с `[KEY]`. Partial/unknown не разрешают автоматический повтор или очистку. Сверка отдельно показывает совпадение конфигурации; успех требует точного generic VERIFIED checkpoint. Copy revision не подменяет подтверждение native-результата. Handshake, маршрутизация и пользовательский трафик импортом не проверяются. + +Basic PATCH/alias/reconcile прошли на [двух CHR](mcp-chr-wg-interface-update-67ca5c07-acceptance.json) с удалением всех собственных отключённых fixtures и восстановлением baseline. Native ID equivalence подтверждена на этих fixtures; native multi-peer import/activation и окончательная сетевая приёмка открыты. Update tools имеют версию 3/proofRevision 2, business create — 3/native create — 2: прежние незапущенные планы нужно подготовить заново. Guards принимают только подтверждённую public identity эквивалентных X25519 scalar representations; raw ключи и команды не переписываются. + +На том этапе secure export оставался неподключённым. После явного разрешения пользователя он подключён и принят в текущей целевой SQL/HTTP-проверке, описанной в начале документа. Ручные collectors по-прежнему ожидают разрешения глобальных уведомлений. Третий CHR для независимой Home → JumpHost → ExitNode цепочки отсутствует. + +Предыдущий этап 24 октября — WireGuard PATCH, пакет событий и совместимость Route AI — локально принят: [протокол и отпечатки исходников](mcp-interface-events-compatibility-acceptance.json). Повторная полная последовательная регрессия завершилась с кодом 0: все 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper, MCP-модели, build и ESLint прошли. UI не менялся; результаты предыдущих 16 браузерных сценариев, двух helper-тестов и frontend TypeScript унаследованы после сверки hashes исходников и логов. Реестр: **335 инструментов; 174 mediated / 62 excluded / 26 pending из 262 REST-маршрутов**. Полный план остаётся открытым. + +`mm_wireguard_interface_edit_plan` меняет поля интерфейса и основной адрес через общий REST/MCP сервис. Закрытый ключ передаётся моделью только как owner/purpose/server secret reference. Поддерживаются rename внутренних peer/address ссылок, вторичные адреса сохраняются. Внешние routing/firewall/application зависимости требуют отдельного плана. Native alias и общий interface editor вызывают тот же сервис; их версия — 2. Native PATCH/readiness ещё не приняты: на двух доступных CHR текущие WireGuard-каталоги пусты. + +`mm_events_batch_append_plan` добавляет до 200 записей клиентского журнала после человеческого подтверждения. Поля REST сохраняются; нормализованный JSON ограничен 64 КиБ/12 уровнями. Содержимое пакета остаётся в зашифрованном плане, UUID генерируются сервером, INSERT атомарен. Эти записи не являются доказательствами выполнения указанных в eventType/sourceModule действий. + +Для глобальной read-only сверки отдельно выдайте `operations.global.reconcile` / `mm_global_operation_reconcile` вместе с исходным действием и full global/server scope. В портале прежняя кнопка сверки сама выбирает глобальный обработчик. Совпадение состояния без точного VERIFIED checkpoint не подтверждает выполнение неизвестной операции; повторной записи нет. + +Route AI возвращает `candidateDiagnostics`: недоступный резерв не делает неизвестным здоровый выбранный путь. У отключённых BGP peers сохраняются private proofs и проверки dormant input/output references. Ошибки авторизации/JSON, смена источника, отмена и лимиты прерывают весь контекст. Положительная версия старого BGP включает только `legacy-read-only` с `instance: null`; запись и native pre-7.20 приёмка открыты. Контракт версий описан в [документации MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/328220/BGP). + +Ручные collector MCP-операции пока не выданы: их существующий глобальный hook может обработать и отправить ожидающие Telegram-уведомления. Добавление guarded scheduler runner отклонено автоматической проверкой до отдельного разрешения пользователя; patch не применён. Общая REST-логика и опциональные access guards подготовлены и проверены только локальными имитациями. Никаких реальных уведомлений в этом этапе не отправлялось. + +Предыдущий этап 24 октября — scoped Community и терминальная диагностика — локально принят: [исторический протокол и отпечатки исходников](mcp-scoped-route-diagnostics-acceptance.json). Исторический реестр: **332 инструмента; 172 mediated / 62 excluded / 28 pending из 262 REST-маршрутов**. Focused SQL/HTTP и полная последовательная регрессия всех 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper, все 16 браузерных fixture-сценариев, два frontend helper-теста, модели, build, TypeScript, ESLint и diff check прошли. + +В **MCP → Route AI → Контрольная проверка пути** выберите назначение: **Путь Community** учитывает BGP/owned rule и может служить допуском к отдельному плану переключения; **Самостоятельный контрольный путь** проверяет probe VRF, интерфейсы, адреса и hops без BGP. Второй результат не допускает переключение Community. В `mm_route_path_verify` v2 параметр `proofPurpose` равен `community` по умолчанию или `control-path`. Старые подтверждения без purpose требуется получить заново. Обратный путь и весь пользовательский трафик этой пробой не подтверждаются. + +`mm_route_optimizer_plan` v3 поддерживает ручной human-approved scoped Community план: явный BGP context, точная native identity, один PATCH только поля `rule`, свежие SLA/Community path proofs, source/protection fences и точный VERIFIED checkpoint для read-only reconcile. Скрипты, comments и seals остаются приватными. Scoped controller и автоматическое восстановление пока недоступны; UI блокирует такую активацию и позволяет сохранить отключённый профиль. + +`mm_server_terminal_diagnostic` / `servers.terminal.read` принимает `serverId`, типизированный `diagnostic` и параметры выбранного сценария. Это тот же allowlist, parser и форматирование, что REST-терминал: help, адреса, интерфейсы, resource/identity, маршруты/firewall, BGP/OSPF/BFD, логи и ping. Сохраняется право `mm:servers:write`; планы не создаются. Help обходится без credentials/native I/O. Ping явно помечен как отправка контрольных пакетов; произвольные CLI-команды не принимаются. + +Предыдущий этап 24 октября локально принят: [исторический протокол и отпечатки исходников](mcp-routing-context-adapters-acceptance.json). Исторический реестр: **331 инструмент; 171 mediated / 62 excluded / 29 pending из 262 REST-маршрутов**. Focused SQL/HTTP и полная регрессия всех 27 backend-профилей, SQL `/logs`, настоящий dump/restore/tamper и 14 браузерных fixture-сценариев прошли. + +- `mm_acme_settings_update_plan` / `certificates.acme.update`: общая с REST форма directory/zone и owner/purpose secret reference для Cloudflare; retain/replace/clear, human approval, private source/CAS и read-back. Сырой токен модели не выдаётся. +- `mm_wireguard_peer_add_plan` / `network.wireguard.peers.create`: полная форма peer, допустимые зависимости существующего родителя, private key reference или режим auto/none, staged disabled и отдельное включение. Старый native alias `mm_wireguard_peer_create_plan` вызывает тот же защищённый сервис. +- `mm_database_backup_plan` / `database.backup`: обязательное человеческое подтверждение, полный глобальный scope, общий с REST export/event и исключительный доступ на время экспорта. Для MCP — 64 МиБ/120 секунд, временный закрытый libpq service file и отказ неподдержанных URI до approval. Модель получает квитанцию; зашифрованный dump доступен 15 минут только JWT владельца с settings admin. Существующий REST export сохраняет прежний контракт размера/времени. + +Route AI отдельно хранит installation table, probe VRF и BGP connection/instance/input chain/transport VRF. Подпись Community path-check связана с источником, зависимостями и owned target rule. Public review показывает до/после community/gateway/action и число строк снимков. Main-переключение сохраняет исходный owned comment. Совместимость старой формы BGP, scoped controller и автоматическое восстановление ещё требуют реализации/приёмки; autoRecovery явно блокируется. Трёхузловая независимая traffic-проверка открыта: пользователь подтвердил только два CHR. Новых native-команд CHR на текущем этапе нет. + +Предыдущая приёмка 24 октября — MCP UI и оба CHR: [исторический итог проверки](mcp-artifact-ui-acceptance.json). Срез реестра того этапа: **328 инструментов; 168 mediated / 62 excluded / 32 pending из 262 REST-маршрутов**. + +В разделе **MCP → Планы и операции → Выполнение операций** откройте завершённую операцию `backups.download` и нажмите **Скачать файл**. Квитанция доступна только актуальному владельцу с правом управления backup. Срок пакета — 15 минут; при изменении источника, истечении срока или отзыве доступа потребуется новый подтверждённый план. Модель получает квитанцию, человек скачивает файл по JWT портала. + +Список операций проверяет только подписанные метаданные и текущие права; содержимое backup/S3 и ciphertext не читаются. UI строит URL из UUID, запрещает redirects и освобождает временный Blob URL. Проверены чужой admin, отсутствие backup permission/JWT, unverified/unknown и подменённые шаги, expiry, protection и pause/ABA. Пройдены 11 браузерных сценариев на API fixtures, SQL/HTTP и настоящий dump/restore/tamper, все 27 backend-профилей, SQL `/logs`, build, TypeScript и ESLint. + +Отдельный [конфигурационный GRE прогон на двух CHR](mcp-chr-gre-revision-1d8eb626-acceptance.json) принят: 24 команды, create/no-op/expand/restore/empty и readonly reconcile, все 16 исходных native fingerprints и оба полных private dependency proofs восстановлены, marker-объектов нет, SSH закрыт. Передача трафика, IPv6 readiness и IPsec SA этим не проверялись. Исторические прерванные попытки сохранены отдельно. + +В историческом срезе оставались 32 REST-адаптера и полный non-main/VRF контекст. Текущий принятый контекст и 29 оставшихся адаптеров описаны выше; scoped executor, reconcile/recovery, независимый прямой/обратный трафик и live portal/client gates по-прежнему открыты. + +Предыдущий принятый этап 24 октября: [пять общих REST/MCP-сценариев и приёмка](mcp-application-adapters-acceptance.json). Реестр — **328 инструментов; 168 mediated / 62 excluded / 32 pending из 262 REST-маршрутов**. Протокол содержит source hashes, focused SQL/HTTP, полный backend-прогон и границы сетевой приёмки. Полный план остаётся открытым. + +- `mm_acme_settings_test_plan` / `certificates.acme.test`: один GET фиксированного Cloudflare endpoint с сохранённым токеном после человеческого подтверждения. Модель не передаёт plaintext токена. Общий REST/MCP-сервис ограничивает время/размер и скрывает provider body; результат подтверждает доступ к API, а не выпуск сертификата/DNS. +- `mm_geoip_update_plan` / `geoip.update`: тот же forced collector Country/ASN, что REST; глобальный полный scope и human approval, ETag/304 и частичный результат. Перед сетевыми и файловыми стадиями проверяются каталог, bounded SHA-256 файлов, ETag и run metadata. Свой ожидаемый proof продвигается только после собственного подтверждённого изменения. Исходный файл сохраняется до атомарной `rename(tmp,target)`; неизвестная попытка не повторяется. +- `mm_netflow_overlay_apply_plan` / `traffic.flow.overlay.apply`: обе REST-формы `/api/traffic/flow-overlay` и `/api/traffic/flow/overlay` используют общий staged executor. Он настраивает WireGuard/IPFIX, необязательный GRE FastPath, адреса/маршрут/firewall, host peer/settings и collector/listener. Нужны полный глобальный scope, traffic/network write, settings admin и человек. HMAC заменяет raw keys в источниках плана; каждый native шаг проверяется. Manual host installation, подтверждённый Linux-agent apply и неизвестный host ответ различаются. Reconcile принимает desired только по точному проверенному private intent/receipt/full afterProof, а не по совпавшему графу с чужими сгенерированными ключами. +- `mm_backup_download_plan` / `backups.download`: человеческое подтверждение обязательно даже в autonomous policy. Общий с REST reader сохраняет remote-first/local fallback, ограничен 8 МиБ/45 секундами. Модель получает квитанцию и ссылку, backup body остаётся в зашифрованном подписанном пакете владельца на 15 минут. Перед чтением/расшифрованием/выдачей проверяются актуальные JWT/owner/credential/policy/generation, источник, выбранное происхождение S3/local, server ownership и xmin epochs. Модельный токен не открывает человеческий маршрут. +- `mm_wireguard_interface_create_plan` / `network.wireguard.interfaces.create`: общий с REST create сохраняет optional IPv4/IPv6, все peer/client поля и допустимые Unicode/quoted имена. Ключи модели — только owner/purpose/server references. Создание и адрес/peer готовятся disabled; включение идёт после проверки подготовленных объектов. Полный source/protection proof проверяется также после durable native intent. Сверка нового сгенерированного ключа требует сохранённого конечного checkpoint; partial/unknown не усыновляются по одному совпадению полей. + +Приёмка этих сценариев использовала только локальные HTTP/S3, fake Linux-agent named pipe, ephemeral UDP и временную SQL-БД; реальный Cloudflare, зеркало GeoIP и backup destination не вызывались. Полная браузерная приёмка остаётся отдельным gate. + +Отдельный GRE CHR-прогон [f1147896](mcp-chr-gre-revision-f1147896-auth-interrupted.json): CHR 1 прошёл create/no-op/expand/restore/empty и исходный dependency proof; CHR 2 прошёл первые стадии, но завершение empty остановилось на 401, очистка встретила 500. Всего 22 native-команды; неизвестный план не повторялся. [Отдельная очистка](mcp-chr-gre-revision-f1147896-recovery.json) по новым marker-bound планам завершилась с кодом 0: все 16 исходных native fingerprints совпали, тестовые объекты удалены, SSH закрыт. Приватный исходный HMAC CHR 2 после удаления временного SQL/state уже непереносим; он остаётся недоказанным. Полная двухузловая GRE приёмка, IPv6 readiness, IPsec SA и forwarding этим прогоном не закрыты. + +Предыдущая проверка после восстановления лимита: [протокол](mcp-resumed-checks-acceptance.json). Все 27 backend-профилей, MCP SQL/HTTP, SQL `/logs`, настоящий dump/restore и tamper прошли. Финальные GRE query/fallback проверены отдельным SQL/HTTP/restore-прогоном; итоговый inventory — **323 инструмента, 162 mediated / 62 excluded / 38 pending**. CHR whole-section GRE restore пока не принят: статическое происхождение не подтверждено, выполнение остановлено, отдельная подтверждённая очистка восстановила восемь baseline fingerprints и полный proof зависимостей; [неизменяемая попытка](mcp-chr-gre-revision-1585ecd1-query-unknown.json). Полный план остаётся открытым. + +`mm_netflow_live_observe` (`traffic.flow.live.read`) использует общий с REST `/api/traffic/flow/live` live engine. Возвращает 1–3 выборки с интервалом 2 секунды, rolling window 5 минут, полный DTO с ограничением массивов через offset/limit и пределом ответа 1 МиБ. SQL-область применяется до агрегации; scoped reads не используют глобальный кэш, смешанные пользователи со скрытыми привязками исключаются. Лимит четырёх одновременных наблюдений общий с REST SSE; слот освобождается при ошибке/отмене. Права проверяются перед источником и выдачей каждой выборки. Это конечное наблюдение, а не бессрочная MCP-подписка. Поля live/rate сохраняют семантику REST: наличие listener и ring/window estimates не доказывают свежесть exporter, полное покрытие или forwarding. Приёмка включает настоящий локальный REST SSE, SQL, меняющиеся ticks, cache/scope isolation, отмену и отзыв доступа; [протокол](mcp-resumed-checks-acceptance.json). + +Состояние на 24 октября 2026 года (UTC+7), ветка `codex/mcp-foundation`. Полный объём: [итоговый план](mcp-implementation-plan.md). Актуальное число обработчиков показывает `GET /api/mcp/catalog`; результаты отдельных приёмок приведены ниже с датами. План ещё не закрыт целиком; наличие обработчиков не заменяет сетевую приёмку и проверку покрытия всех REST-операций. SQL-проверка привязки OSPF-плана к замерам выполнена; прежнее ограничение запуска снято. + +## Запуск + +1. Установить зависимости и собрать backend: `npm install`, `npm --prefix backend run build`. +2. Настроить PostgreSQL, `AUTH_REQUIRED=true`, доверенные issuer/JWT secret и точный `CORS_ORIGIN` в `backend/.env`. Без авторизации MCP возвращает 503. +3. Задать `MCP_PUBLIC_URL=https://manager.example/mcp`. HTTPS обязателен за пределами loopback. Reverse proxy должен сохранять Host и обслуживать `/mcp`, `/oauth/*`, `/.well-known/*`. +4. Задать `MCP_STATE_DIR` вне PostgreSQL и дампов, с доступом только сервисному пользователю. Здесь находятся AES-GCM ключ, поколение доступа и внешний журнал восстановления. Реплики backend должны использовать общее согласованное состояние. Потеря ключа делает зашифрованные планы/секреты/выгрузки недоступными. +5. Запустить backend: миграции 0015–0029 применяются штатным bootstrap. Открыть **Система → MCP**, включить MCP и создать ограниченное подключение. Токен показывается однократно. + +По умолчанию MCP выключен. Подключению явно назначаются операции, серверы, срок и лимиты. Новые инструменты не добавляются в существующий доступ автоматически. + +Подтверждённые права владельца действуют до истечения его JWT, максимум 24 часа (без exp — 15 минут). Затем доступ останавливается до «Обновить мои права» в разделе подключений. Обновление берёт текущие проверенные JWT-права и инвалидирует планы/допуски контроллера. Для мгновенного отзыва реализован подписанный webhook портала; отправитель и доставка в реальном окружении требуют настройки. Внешний журнал отзыва переживает restore старой БД. + +## Подключение AI-клиента + +HTTP: Streamable HTTP endpoint `/mcp`, заголовок `Authorization: Bearer <токен подключения>`. JWT портала служит для web-администрирования; токены агента не принимаются обычными `/api/*` маршрутами. + +Stdio: после сборки запустить `npm --prefix backend run mcp:stdio` с переменными `MCP_URL` и `MCP_TOKEN`. Мост использует тот же HTTP endpoint, разрешения и аудит. stdout содержит только MCP-сообщения, диагностика идёт в stderr. Не сохраняйте токен в Git или аргументах командной строки. + +OAuth: зарегистрировать публичный клиент с точными redirect URI в настройках MCP. Реализованы discovery, authorization code + PKCE S256, согласие владельца подключения, resource binding, access token до 15 минут, ротация refresh token и отзыв семейства при повторном использовании. Динамическая регистрация не включена. Redirect требует HTTPS либо loopback. + +## Администрирование и операции + +Раздел `/mcp`: обзор, подключения, симулятор прав, защиты объектов, обращения, планы/операции, дрейф, Route AI, настройки и OAuth-клиенты. Доступны проверка готовности подключения, временные токены до 24 часов, отзыв, ротация и аварийная остановка. Проверка готовности выполняет публичные SDK initialize/tools-list и проверяет доступ; приёмка внешнего клиента требует его реального подключения. + +Права: `mm:mcp:read`, `mm:mcp:admin`, `mm:mcp:approve`, `mm:mcp:pause`; `mm:settings:admin` включает их. Административная выдача ограничена владельцем/администратором. Ошибки остаются видимыми; скрытая вкладка прекращает polling. + +`GET /api/mcp/catalog` — точный актуальный реестр. Только `implementation: available` имеет обработчик и может быть выдано подключению. `POST /api/mcp/policy/preview` — гипотетическая симуляция; результат allowed не выдаёт доступ и не одобряет план. + +Реализованы типизированные чтения и планы серверов, пользователей/привязок, Ethernet, bridge/ports/VLAN, IPv4/IPv6-адресов и маршрутов, tables/rules/VRF, RPF/hash policy, firewall, GRE, WireGuard, VXLAN, OSPF templates, BGP connections, IPsec peers, контейнеров и сертификатов. Сервисные инструменты охватывают backup, события, оповещения, планировщик и историю мониторинга. Наличие базового объекта не означает покрытия всех сценариев соответствующего раздела. + +Пользователи — каталог `/users`, его серверные права и привязки интерфейсов. Это не аккаунты auth-portal или локальные пользователи RouterOS. + +Логи используют существующий collector/PostgreSQL: поиск, summary, контекст, bounded follow, настройки и экспорт до 50 000 записей. Ограничение серверов применяется в SQL до выдачи/агрегации. Курсоры зашифрованы и связаны с подключением, фильтрами и поколением доступа. Зашифрованные выгрузки живут 15 минут и скачиваются через авторизованный API. CSV защищён от формул. + +Изменение проходит подготовку, подтверждение и применение. План содержит конкретные ID, снимки, шаги, версию политики и срок. Исполнитель повторно проверяет доступ, атомарно резервирует бюджет, сохраняет intent перед записью и проверяет каждый результат чтением. Изменённый снимок отклоняет план. Idempotency key не повторяет запись. После потери процесса незавершённое действие становится unknown без автоматического retry. Общие PostgreSQL-блокировки сериализуют конфигурационные записи UI/MCP. + +Общий RouterOS-клиент сохраняет аудит до отправки любой конфигурационной записи. Защищённые объекты нельзя обойти через старые REST-обработчики или scheduler: без проверенного плана неизвестная область зависимостей блокируется консервативно. Для глобальных сервисных изменений проверяется полный текущий каталог серверов; его изменение инвалидирует план. Подтверждение команды полного router restore отдельно помечается как acknowledgement/inconclusive, а не как проверенная связность. + +## Маршрутизация + +- Метрики содержат реальные RTT/loss/DL/UL, отдельные времена ping/bandwidth, направление, интерфейсы, family и VRF. Неизвестные/устаревшие данные не получают выдуманный рейтинг. Новый ping сохраняет независимый актуальный bandwidth. Обратное направление меняет DL/UL местами. +- Community-план использует существующий генератор управляемых BGP-фильтров, проверяет gateway/принадлежность, unmanaged-конфликты и пригодность выбранного пути по свежим измерениям/SLA. Перед записью эта пригодность проверяется повторно без выбора другого пути. Отдельная таблица Community пока отклоняется: генератор приложения не подтверждает её изоляцию. +- OSPF-план сохраняет template ID и costs, учитывает peer ownership, instance/family/table и свежесть измерений. Снимки соседств, areas и instances собственного/зависимых устройств проверяются перед выполнением; срок пригодности измерений ограничивает каждый шаг. При apply рейтинг не пересчитывается. Равные costs задаются явно; локальный рейтинг не выдаётся за глобальный оптимум. +- После всех cost-записей повторно проверяется Full выбранных OSPF-соседей и Up включённых BFD-сессий с привязкой к адресам/интерфейсу/VRF; эти протокольные инварианты сохраняются в обратных native-планах. Для ECMP до записи проверяется точный resolved next-hop, после — active выбранных маршрутов. Обратный ECMP-план может намеренно вернуть один маршрут в неактивное higher-distance состояние, поэтому прежний active-инвариант на него не переносится; связность проверяется контрольными пробами. Подтверждённая потеря здоровья даёт partial, недоступный ответ — unknown; команды не повторяются. Расширенная сквозная/аварийная приёмка ещё не завершена. +- ECMP-план выравнивает distance выбранных активных управляемых маршрутов одного prefix/table и меняет подтверждённую устройством hash policy. Активность next-hop повторно проверяется перед записью. NAT/FastTrack/RPF не меняются автоматически. +- RPF требует подтверждения риска асимметрии; strict отклоняется при нескольких таблицах/VRF. VRF-план проверяет состав существующего управляемого VRF, конфликты и снимки зависимостей; скрытый route leaking не создаётся. +- Профиль создаётся в web-редакторе Route AI или через `mm_route_profile_save_plan`. Владелец может проверить, подтвердить и применить план в UI. Контроллер хранит состояние в PostgreSQL, использует межпроцессный lease, исходные credentials/политику и допуск до 24 часов, ограниченный актуальностью прав владельца. Учитываются pin/exclusion/SLA, hysteresis, новые образцы, cooldown и часовой лимит. Изменения проходят обычные планы/подтверждения. Отзыв доступа, смена политики, истечение допуска, дрейф, изменение ожидаемого пути и неоднозначный результат останавливают профиль. + +Конфигурация маршрута и наблюдение контрольных пакетов различаются. `mm_route_path_verify` использует ограниченные native ping/traceroute, зарегистрированные IP интерфейсов и порядок обязательных hops. При нескольких адресах требуется явный IP. В traceroute берётся последняя `.section`, секции не объединяются; пропущенные промежуточные hops остаются неизвестными (`completeTrace:false`). Это не доказательство пути всего пользовательского трафика. + +Management-канал регистрирует человек: отдельный IP, зашифрованный секрет, срок до 24 часов и JWT, сверка стабильного RouterOS license ID. Алиас основного IP/другое устройство отклоняются; перед стадиями повторно проверяются срок, отзыв, доступность и идентичность. Физическую независимость пути подтверждает администратор: отдельный IP её не доказывает. + +Community допускает ограниченную автономность только при явной политике, свежих метриках и прошедшей проверке пути, проверенных management-каналах всех зависимостей и отсутствии блокирующих защит. Остальные нативные сетевые записи требуют человека. Failover контроллера требует трёх разных подтверждённых отказов и свежего прошедшего резерва; unknown не считается отказом. + +Автовосстановление Community отдельно включается человеком в сохранённом профиле. Бюджет резервируется вместе с исходной операцией. Возврат допускается только после подтверждённой записи и неуспешной проверки, при неизменных checkpoint/gateway, действующих допусках и ранее подтверждённом исходном пути. Сначала сохраняется recovery intent; unknown не повторяется. Исходная операция остаётся `partial`, даже если восстановление проверено. + +`mm_operation_reconcile` читает состояние по зашифрованным checkpoints. `mm_operation_recovery_plan` готовит отдельный человеческий обратный план для проверенных native create/update без внешнего дрейфа. Для deletes и сервисов универсального inverse нет: нужен собственный revisions/restore либо явный отказ. + +## Новые сервисы, web UI и отзыв порталом + +Настройки traffic/uptime/NetFlow и каталоги Ping/bandwidth-проб используют общие UI/MCP-сервисы. Полная замена каталога показывает удаляемые ID, проверяет все серверные зависимости/защиты и требует подтверждения. Смена параметров bandwidth сбрасывает прежние измерения; MCP ограничивает duration 3–30 секундами. Контроллер сам bandwidth не запускает. + +NetFlow analytics/monthly выбранного сервера фильтруются в SQL до объединения потоков; глобальные topology labels не выдаются. Host status/reconcile используют настроенный локальный агент и сохранённую конфигурацию. Проверка apply требует reachable/interfaceUp/configMatches; несовпадение остаётся `partial`. Общие guards также охватывают CRUD серверов/пользователей, настройки backup/cert renewal/collectors и полный restore. Покрытие всех прочих DB/host-путей ещё не объявляется завершённым. + +Обычный PATCH пользователя не принимает роли/права; для них есть отдельный административный инструмент. Исправлены creation defaults в Zod PATCH: имя пользователя не сбрасывает роль/привязки, имя сервера не сбрасывает пароль/endpoint/TLS. Привязки поддерживают реальные ether/bridge/vlan/vxlan/gre/wg/ipsec/other. + +Временный grant сужает также каталоги, SDK tools/list, конфигурации профилей и содержимое старых планов/операций. Скрытые серверные привязки пользователя фильтруются с `incomplete:true`; чтение или отмена плана вне области grant запрещены. Для пользователя есть отдельный get по ID и каталог/состояние интерфейсов для привязок. + +Настройки API-ping и Internet path используют общие защищённые сервисы. Статистика и pivot вызывают существующий SQL-алгоритм с обязательным serverId и периодом до 31 дня; native обновление интерфейсов ограничено этим сервером. По умолчанию, как в обычном UI, учитываются привязанные интерфейсы; явный `iface` выбирает конкретный интерфейс. Проверка двумя различными SQL fixtures подтверждает отсутствие чужих данных и native запросов. + +Реестр [backend/mcp-coverage.json](../backend/mcp-coverage.json) охватывает 262 маршрута: 168 сопоставленных сервисных сценариев, 62 обоснованных исключения транспорта/авторизации/управления самим MCP, 32 ожидающих адаптера. Его показывает административный UI. `test:mcp` сверяет реальный Fastify route inventory с файлом; новый endpoint требует явной записи. Обновление inventory: `node --import tsx backend/src/scripts/mcp-coverage.ts --update` из корня. Скрипт сохраняет существующие решения, новые бизнес-сценарии остаются pending. Проверка реестра не выдаёт pending за готовую реализацию и не объявляет native CRUD заменой всех высокоуровневых REST-сценариев. + +`mm_server_wan_runtime_get` использует общий с REST `readWanRuntimeForServer`: три native-чтения DHCP/address/route только выбранного включённого сервера. Возвращает IPv4/main, activeDefaults, defaultRouteStatus confirmed/none/unknown/ambiguous; ECMP или gateway-list не выбирает первый WAN. Для DHCP нужен bound, неоднозначные адреса не превращаются в единственный leasedIp. Состояние forwarding не подтверждается. MCP ограничивает страницы uplinks до 200, список адресов на uplink до 100, activeDefaults до 200 и показывает усечение; web API сохраняет поля прежнего DTO и дополнительные признаки достоверности. Неизвестные table/active/distance или противоречивые route flags не заменяются догадкой. + +`mm_bgp_sessions_get` использует общие с REST reader/parser/poller для общего и per-server чтения: scope до опроса, пагинация серверов/сессий, failures/incomplete при частичном отказе, null для неизвестных счётчиков. `mm_bgp_sessions_raw_get` читает выбранный включённый сервер, добавляет его метаданные, ограничивает страницу и скрывает native-ключи до выдачи и аудита. Ошибочная структура или повторяющиеся ID не считаются успешным пустым parsed-каталогом. Raw — диагностические данные с общими ограничениями/redaction MCP, а не подтверждение передачи трафика. + +`mm_ospf_optimizer_analyze` доступен наблюдателю с network:read. Общий с `mm_ospf_optimizer_plan` расчёт выдаёт до 50 строк рейтинга: текущий/предложенный cost, score, замеры, peer и срок пригодности. Он не создаёт план и не отправляет изменения. Для применения нужен новый план и человеческое подтверждение. Учитывается последнее наблюдение точного server/peer/interface/family/table; новое плохое или неподтверждённое наблюдение не замещается старым хорошим. Порог maxLossPct по умолчанию 1%, явный допуск передаётся в анализ/план. Этот анализ не объявляется эквивалентом legacy web optimize/preview с другой моделью измерений: отдельные REST-адаптеры legacy preview/apply реализованы через `mm_ospf_application_preview` и `mm_ospf_application_optimize_plan`. + +Перед guarded-записями OSPF повторно проверяются зафиксированные observation ID/fingerprint/validUntil без пересчёта одобренного рейтинга. Модульная и SQL-приёмка пройдены: новое плохое наблюдение, изменение сохранённой строки и истечение пригодности после approval блокируют запись. Прежнее ограничение запуска временной PostgreSQL снято. + +`mm_geoip_get` использует общий с REST `buildGeoipStatus`: локальные readers, состояние обновления и настройки. `mm_geoip_settings_update_plan` меняет enabled/updateIntervalSec через общий guarded-сервис, требует полный эффективный каталог серверов и подтверждение человека. Fingerprint содержит только настройки; служебные lastCheck/lastError/buildAt не отменяют план. Проверяется сохранение каждого изменённого поля. Перезапуск расписания при включённом GeoIP немедленно запускает проверку зеркала; это указано в предупреждении. Отключение не отменяет уже начатую загрузку. Успех операции подтверждает сохранение настроек, готовность баз проверяется отдельно; принудительный `/geoip/update` ещё ожидает адаптера. + +`mm_evobgp_settings_get` возвращает baseUrl/enabled/secretConfigured без ключа и без создания строки БД. `mm_evobgp_settings_update_plan` принимает `settings: {baseUrl?, enabled?}` и необязательный `apiKeyRef`: UUID секрета владельца с purpose `evobgp-api:<нормализованный URL>` без serverId; null явно удаляет ключ. При изменении URL сохранённый ключ нельзя перенести без новой привязанной ссылки или явного удаления. План зашифрован, ключ скрыт в выдаче и журналах; смена ключа между подтверждением и применением инвалидирует план даже при неизменном secretConfigured. Сохранение проверяется включая ключ без его выдачи. Общий сервис REST/MCP соблюдает `evobgp-settings:1`, серверные защиты и аудит; бюджет учитывает до двух записей инициализации/обновления. Проверка доступности EvoBGP реализована отдельным адаптером общего с REST module GET; результат не доказывает передачу BGP-трафика. + +`mm_evobgp_catalog_get` читает включённую интеграцию по сохранённым URL/ключу, с полным эффективным каталогом серверов до HTTP и SQL-агрегации. Общие с REST парсер, formatter и подсчёт уникальных серверов сохраняют неизвестный prefixCount как null. На каждую коллекцию действует offset/limit до 200, pages содержит total/nextOffset, catalogVersion позволяет заметить изменение исходных данных между страницами. Исходный ответ ограничен 8 MiB и 15 секундами вместе с телом; redirects запрещены, отмена вызова прерывает fetch. Ошибки не превращаются в пустой успешный каталог. Даже отражённый upstream ключ в обычном строковом поле скрывается перед выдачей/журналированием. Значения каталога описывают данные EvoBGP и локальные привязки фильтров; фактический обмен BGP-префиксами подтверждается отдельно. + +Симулятор бюджета проверяет сумму уже использованного лимита и полной стоимости плана; точное заполнение оставшегося бюджета допустимо. Зарезервированная обратная операция включается в расчёт. Применение повторно проверяет бюджет под транзакционной блокировкой: расход после предварительной проверки не позволяет перерасход. SQL-приёмка проверяет отказ до создания пакетного плана и расход между подтверждением и применением. + +`mm_gre_catalog_get` использует те же `fetchServerFilters`/`pollGreCatalog`, что `/api/filters/gre-tunnels`. Список разрешённых включённых серверов ограничивается до сетевых запросов: до 10 устройств за вызов, до 200 туннелей на страницу. Есть отдельные server/collection offsets, `failures` и `incomplete`; недоступный CHR не превращается в успешный пустой каталог. Выдаётся состояние IPsec без ключа. + +`mm_{filters,recursive_routes,firewall,wireguard,ipsec,gre}_revisions_get` возвращают историю через общий `listRevisions`: сервер, раздел, источник, fingerprint, дату, заметку и число элементов. Максимум — 50 последних записей; достижение предела консервативно помечается `incomplete:true`. Права и server scope проверяются до SQL. Сохранённый payload не выдаётся: он может содержать ключи туннелей. Фильтры требуют `mm:filters:read`, остальные разделы — `mm:network:read`. Восстановление recursive-routes, filters, firewall и GRE реализовано отдельными общими REST/MCP-сервисами ниже; WG/IPsec restore остаются pending. + +После изменений маршрутизации 18 октября повторно прошли все backend-профили, SQL/live logs и настоящий `pg_dump`/`pg_restore` на собственной временной БД (`Regression failed profiles: none`). После добавления GRE-каталога и истории отдельно повторены MCP-интеграция с PostgreSQL, сборка, unit-профиль и ESLint затронутых адаптеров. Web UI в этом блоке не менялся; девять E2E относятся к предыдущей проверке интерфейса. + +Следующий блок добавил общие строгие сервисы `mm_recursive_routes_catalog_get`/`mm_recursive_gateways_catalog_get`: до 200 элементов на страницу, сохранение observed-кэша и ревизии только после подтверждения полного управляемого набора. Недостающие `disabled`/`check-gateway` читаются через native `/get`; неподдерживаемый owned-маршрут или ошибка опроса не подменяются кэшем. Состояния шлюзов — `active`/`inactive`/`unknown`. + +`mm_containers_state_get`/`mm_certificates_state_get` используют общие state collectors с тем же отбором серверов до опроса и двумя уровнями пагинации. Сохраняются per-server ошибки, `unsupported`, частично доступные зависимости, неизвестные runtime/date/boot значения; native даты сертификатов требуют стабильного наблюдённого GMT offset. Все env values и произвольный cmd контейнеров скрыты до результата и аудита. Каталог контейнеров на реальных CHR подтвердил отсутствие пакета как `unsupported`; заполненные/частично доступные каталоги проверены локальным RouterOS-имитатором. + +`mm_server_snapshots_get` читает до 200 последних snapshots через общий SQL-сервис выбранного сервера. `mm_server_source_address_get` принимает только ID зарегистрированного сервера: результат разрешения управляющего host имеет `interfaceAddressVerified:false`; DNS/управляющий IPv4 не заменяет проверку native адреса интерфейса. + +`mm_ecmp_preflight_get` принимает семейство и 2–8 ID маршрутов. Читает десять фиксированных ресурсов: маршруты, family settings, filter/NAT/mangle/raw, routing rules/tables/VRF и connection tracking. Выдаёт ограниченный список выбранных маршрутов и findings с ID/числом затронутых объектов. Отсутствующие свойства, неоднозначные ID, недоступные ресурсы и неподтверждённый контекст остаются `unknown`. Неизвестные gateway не объявляются одинаковыми. Повтор next-hop или общего egress, NAT, FastTrack, mangle/rules, notrack, отключённый tracking и strict RPF требуют проверки человеком. Автоматическое изменение hash/NAT/firewall/RPF не выполняется; результат всегда `review-required`, `networkValidation:not-performed`, `independentPaths:not-proven`. Правила рассматриваются консервативно: совпадение реальных пакетов/переходов не вычисляется. Основания проверок: [RouterOS IP Settings](https://help.mikrotik.com/docs/spaces/ROS/pages/103841817/IP%2BSettings), [Connection tracking](https://help.mikrotik.com/docs/spaces/ROS/pages/130220087/Connection%2Btracking), [Packet Flow](https://manual.mikrotik.com/docs/firewall-and-quality-of-service/packet-flow-in-routeros/). + +Read-only приёмка на обоих CHR 7.24.5: [отчёт каталогов и preflight](mcp-chr-catalog-acceptance.json). В последнем запуске проверены 20 видов чтения, включая OSPF/BFD/firewall, интерфейсы Ping/speed-test и live-трафик выбранного интерфейса; все десять источников preflight IPv4/IPv6 доступны. Нет операций изменения устройства, 15 конфигурационных fingerprints на каждом CHR совпали до/после, включая восемь таблиц firewall. OSPF/BFD/firewall в этом запуске подтверждены как пустые; непустые/неизвестные/ошибочные состояния проверяются отдельно имитатором и SQL. Проверялись существующие маршруты, включая connected/разный контекст: их наличие не объявляется готовой ECMP-группой. Реальный NAT/FastTrack-трафик и физическая независимость этим отчётом не приняты. Временная БД не содержит историю приложения, поэтому пустой server history в стендовом отчёте дополняется SQL fixtures с двумя серверами. + +Последний запуск этого отчёта завершён 18 октября в 11:25:43 UTC: на каждом CHR проверены десять видов чтения, включая новые WAN/BGP sessions/raw. Оба WAN получили ambiguous из-за нескольких активных default routes, BGP sessions подтвердили пустой список. Конфигурация не менялась; SSH-туннель закрыт. Обмен BGP-префиксами этим запуском не подтверждается. + +Запуск read-only приёмки: `node backend/scripts/test-mcp-postgres.mjs --chr-read` из корня, с `MMAPP_TEST_CHR_HOSTS`, `MMAPP_TEST_CHR_USER` и при необходимости `MMAPP_TEST_CHR_TUNNEL_PORTS`. Пароль вводится в скрытом терминальном prompt. Работает тот же явный allowlist двух тестовых CHR и собственная временная PostgreSQL; mutating overlay-флаги в этом режиме не исполняются. После этого блока повторены build, MCP unit/integration, общие recursive/containers/certificates профили и ESLint. Полная backend-регрессия и restore выше относятся к предыдущему блоку. + +`mm_container_remove_plan` использует тот же `removeContainer`/`removeStoppedContainer`, что REST remove: явный stopped, свежие повторные чтения и подтверждение отсутствия ID. Подготовка фиксирует конфигурационную идентичность контейнера и интерфейс-родитель (ID/name/type/disabled); обе формы защиты `interface` и native type участвуют в evaluator. Свободный cmd/env/mount config входит в fingerprint и не возвращается текстом. Missing disabled родителя сохраняется как `null`, без предположения о его состоянии. Native `mm_container_delete_plan` делегирует ту же подготовку и не обходит stopped/protections. Остановка, удаление интерфейса, mounts/env или root-dir не добавляются скрыто. Обратного плана удаления нет; unconfirmed read-back остаётся `unknown`, idempotency не отправляет повторный DELETE. + +Start/stop/restart дополнительно сверяют fingerprint конфигурации и родителя перед командой и при проверке результата. Подмена контейнера при прежнем ID или изменение родителя после подтверждения блокируют исполнение; подмена после подтверждённой команды не считается успехом. Изменения проверены MCP-интеграцией с собственной PostgreSQL и имитатором RouterOS: running/unknown/error нельзя удалить, parent protections действуют для прямого/alias-инструмента и start/restart, stale configuration/state/parent отвергаются, restart остановленного/работающего контейнера исполняет нужные стадии, неуспешный read-back не повторяет DELETE. Реальная проверка удаления на двух CHR не выполнялась: пакет containers отсутствует, что зафиксировано предыдущей read-only приёмкой. + +`mm_recursive_revision_restore_plan` требует `serverId`, `revisionId` и `mm:network:write`. Ревизия выбирается в SQL по ID/серверу/разделу, её payload сверяется с сохранённым fingerprint и существующей REST-схемой. План заменяет полный owned-набор recursive-routes: до 50 текущих и до 50 целевых строк. Чужие маршруты не удаляются; пустая ревизия явно удаляет весь owned-набор. Используются общие `applyRecursiveToServer`/`executeRecursiveApply`, создаётся metadata/revision с источником rollback, native ID и cache ID меняются. Поэтому ссылки BGP-фильтров `rec:` блокируют план; новые ссылки проверяются повторно перед записью, не переписываются скрыто. Фиксируются снимки маршрутов/таблиц/rules/VRF/адресов и конкретные интерфейсы gateway, учитываются серверные защиты полного сервисного изменения. Native effective flags читаются также перед REST-применением, чтобы omitted `disabled`/`check-gateway` не превращали корректный owned-набор в ошибочный. + +Ревизия, набор и зависимости повторно проверяются при исполнении; непосредственно перед первым PUT ещё раз сверяется observed owned-набор. Только после native операций сохраняется cache/revision. Read-back сравнивает полный набор как мультимножество, включая дубли, явный ноль и disabled/check-gateway; новые ID и metadata country не считаются native-дрейфом. Nullable scope/targetScope сохраняют REST-смысл «не задано, применяются defaults RouterOS» и не объявляются точным восстановлением неизвестного старого effective scope. Ошибка проверки даёт partial, потеря подтверждения — unknown без автоматического повтора или универсального inverse. Изменения NAT/RPF/management-пути не добавляются; проверка трафика выполняется отдельно. + +Для пакетного сервиса сохраняется рассчитанная сервером `mutationCost`: create + delete + одна запись metadata. Исполнитель проверяет её в лимите объектов и резервирует весь часовой бюджет до запуска, независимо от одного service-step. Нулевой/отрицательный/дробный, слишком большой или меньший числа шагов cost отвергается. Для прежних планов без этого поля сохраняется стоимость по числу шагов. Отказавшиеся/неопределённые операции не возвращают зарезервированный бюджет автоматически. + +Обычный исполнитель передаёт общим RouterOS/application guards проверку текущего допуска: credential, owner freshness, версия политики, temporary grant и защиты проверяются также перед каждой записью внутри сервисного шага. Уже зарезервированный бюджет повторно не списывается. SQL-интеграция останавливает доступ сразу после первого PUT и подтверждает, что следующий DELETE не отправлен, исходный owned-маршрут сохранён, исход операции unknown и автоматического повтора нет. Уже отправленная запись не отменяется отзывом. Отдельный trigger-тест проверяет несоответствие cache при корректных native/revision данных: оно тоже не считается успехом. + +Приёмка восстановления на двух CHR 7.24.5 — [отчёт](mcp-chr-recursive-restore-acceptance.json). Стенд сначала требует отсутствие прежних owned recursive-routes. Используется уникальная non-FIB таблица и один отключённый маршрут; человеческие планы проверены при distance 10→25, пересоздании ID, сохранении metadata и очистке пустой ревизией. Конфигурационные fingerprints маршрутов/таблиц восстановлены на обоих CHR. Трафик не переключался, connectivity/failover этим тестом не принимаются. Запуск: `node backend/scripts/test-mcp-postgres.mjs --chr-recursive-restore` с теми же переменными подключения и скрытым вводом пароля. Перед каждым auto-approval стенд проверяет, что полный заменяемый набор принадлежит только его marker/table; неожиданное владение или дрейф блокируют работу. + +После восстановления recursive-routes повторно прошли все backend-профили, SQL/live logs и настоящий terminal pg_dump/pg_restore (`Regression failed profiles: none`). Дополнение проверки отзыва внутри service-step отдельно подтверждено повторной полной MCP-интеграцией; сборка и unit/ESLint также проверены. Новые изменения UI отсутствуют, прежние web E2E в этом блоке не перезапускались. + +В UI есть URL-фильтры обращений по подключению, инструменту, пользователю, серверу, состоянию и периоду; время последнего успешного обновления; сравнение исходного состояния и шагов плана с предупреждениями/зависимостями. Аварийная остановка выключает MCP и активные профили. Очередь ограничена 100 pending/running операциями, локальных исполнителей — два. + +Для webhook настроить отдельный случайный `MCP_PORTAL_WEBHOOK_SECRET` от 32 символов на backend и портале. `POST /_internal/mcp/portal/revocations`, JSON до 4096 байт: + +```json +{"eventId":"","userId":"","type":"permissions.changed"} +``` + +Допустимы также `user.disabled`, `sessions.revoked`. `X-MM-Timestamp` — Unix seconds; `X-MM-Signature` — `sha256=`. Окно — ±5 минут. Доставка по HTTPS; неподтверждённое событие повторяется с тем же eventId/телом и новой подписью. Принятый повтор идемпотентен, другой payload с тем же ID отклоняется. + +Отзываются credentials/grants владельца, незапущенные планы и контроллеры. Отсечка JWT сохраняется вне PostgreSQL и переживает restore; после отзыва нужен `iat` позднее события. Приёмник и его проверки готовы, но отправку событий из настоящего auth-portal ещё нужно настроить и принять. Один backend не может самостоятельно узнать о смене прав в портале. + +## Терминальное восстановление PostgreSQL + +На backend нужны совместимые `pg_restore`/`psql` в PATH; для создания архива — `pg_dump`. Наличие restore-утилит проверяется до плана. + +В настройках MCP администратор загружает PGDMP custom архив до 64 МиБ. Зашифрованный файл вне БД привязан к владельцу/поколению и живёт до 15 минут; одновременно до четырёх архивов. Модель получает `archiveRef` и SHA-256 для `mm_database_restore_plan`. Произвольный путь, SQL и загрузка моделью не поддерживаются. + +Требуются settings admin, глобальный доступ ко всем серверам и отдельное подтверждение. Учитываются защиты всех доменов и legacy REST. Исполнитель не удерживает обычный request admission: exclusive gate дожидается запросов/consumers, затем повторно проверяет допуск, одноразово помечает архив и отзывает MCP-поколение до записи. Потерянная команда не повторяется. + +Restore может удалить исходную строку операции. Человек читает квитанцию вне БД через UI либо `GET /api/mcp/database/receipts/:operationId`: running/applied/blocked/unknown. Ошибки миграций/журнала/перезапуска после подтверждённого restore показаны отдельно и не запускают retry. + +Для настоящей изолированной restore-проверки задать `MMAPP_PG_CLIENT_BIN` с каталогом `pg_dump` и `pg_restore` PostgreSQL 18 либо поместить их в PATH и передать `--restore` в `test:mcp:pg`. Runner проверяет запуск обеих утилит до integration fixtures и при их отсутствии завершает прогон с очисткой своего временного кластера. Windows embedded PostgreSQL не содержит client tools. Некоторые Windows sandbox блокируют WAL rename: тогда кластер запускается вне sandbox, сохраняя ту же изоляцию БД. + +## Проверки + +```powershell +npm --prefix backend run test:mcp +npm --prefix backend run test:mcp:pg +npm --prefix backend run test:mcp:pg:all -- --restore +npx playwright test --config playwright.mcp.config.ts +npm --prefix backend run build +npx tsc --noEmit --incremental false +``` + +`test:mcp:pg` создаёт временный PostgreSQL 18, не использует БД приложения/локальную SQLite, запускает миграции, fake RouterOS и реальные HTTP/stdio SDK-клиенты, затем останавливает кластер. Проверяются OAuth/PKCE/replay, scopes, atomic budgets, approval, intent, idempotency, snapshot conflict, read-back, drift, persistent controller, runner leases и внешнее поколение доступа. `:pg:all` запускает backend-профили и SQL-тесты логов на отдельной временной БД и сообщает упавшие профили. E2E проверяют web UI с изолированными API fixtures, отдельно от протокольной интеграции. + +## Оставшиеся работы + +Полная policy-граница и покрытие всех оставшихся DB/host и REST-операций; настройка отправителя webhook в настоящем auth-portal; приёмка подключения конкретных внешних AI-клиентов; полная трёхузловая CHR-приёмка Community/BGP/OSPFv2/v3/ECMP/VRF, аварий и сетевого восстановления. Серверный протокольный handshake реализован отдельно. Реализованные проверки и ограничения перечислены ниже; fixture не заменяет реальную сетевую приёмку. + +Вкладка «Интерфейсы» предоставляет Ethernet, bridge/ports/VLAN, IPv4/IPv6-адреса и VRF. Серверы берутся из области собственного подключения, свойства — из типизированных схем backend; в план отправляются только изменённые поля. Создание, изменение и удаление требуют отдельной проверки/подтверждения/применения. Системные и динамические объекты доступны только для чтения. Пагинация ограничена 100 строками на запрос; смена подключения/сервера/типа создаёт отдельный контекст редактора. + +Пройдены девять web E2E: доступ, ошибки/mobile, профиль с контрольными пробами, management с очисткой пароля, обратный план, URL-фильтры, изменение интерфейса, человеческая загрузка restore-архива и обзор плана. [Базовая приёмка двух CHR](mcp-chr-acceptance.json) и [контроль пути/обратных планов](mcp-chr-path-acceptance.json) на RouterOS 7.24.5 подтвердили approval, idempotency, read-back и полный lifecycle собственного отключённого bridge с обратным планом. Baseline интерфейсов восстановлен, IPv4/IPv6 routes не изменились. Пробы прошли в обоих направлениях, но JumpHost/ExitNode совмещены на втором CHR. На baseline нет OSPF neighbors/BGP sessions; это не полная приёмка переключения или failover. + +Для повторения базовой CHR-проверки задайте `MMAPP_TEST_CHR_HOSTS` и `MMAPP_TEST_CHR_USER`, затем выполните `npm --prefix backend run test:mcp:pg -- --chr`. Пароль вводится в скрытом терминальном приглашении и не сохраняется в проекте. Скрипт допускает только два согласованных адреса стенда; PostgreSQL одноразовый. При необходимости `MMAPP_TEST_CHR_TUNNEL_PORTS` задаёт по одному локальному loopback-порту SSH-туннеля для каждого CHR. Неопределённый результат изменения не повторяется автоматически; cleanup ограничен собственным отключённым объектом с точным уникальным маркером. + +Режим `MMAPP_TEST_CHR_ROUTING=true` дополнительно проверяет на каждом CHR собственную таблицу с `fib=false` без правил выбора и маршрут `192.0.2.0/24`: создание отключённым, включение с изменением distance, отдельный обратный план и удаление. Таблицы и маршруты после очистки сравниваются с исходными fingerprints. В реальном RouterOS 7.24.5 обнаружен ответ `fib:""` для выключенного FIB; он нормализуется только для этого поля, отсутствие поля остаётся неизвестным. Приёмка проверяет read-back конфигурации, а не прохождение трафика по тестовому маршруту. + +Режим `MMAPP_TEST_CHR_OVERLAY=true` запускает отдельную двухузловую сетевую приёмку и сохраняет [отчёт GRE/VRF](mcp-chr-overlay-acceptance.json). На каждом CHR создаются только собственные GRE, VRF, IPv4 `/30` и IPv6 ULA `/64`; IPv6 должен быть уже включён. Проверяются connected routes исключительно в тестовой VRF и настоящие ping/traceroute IPv4/IPv6 в обе стороны. Отключение только тестового GRE на втором CHR должно дать явные `sent/received` и 100% потерь в обоих семействах, при сохранении REST-управления обоими CHR. Затем отдельный подтверждённый обратный план восстанавливает GRE, а обе контрольные пробы снова должны пройти. Приёмка прошла на RouterOS 7.24.5; RTT около 65–68 мс. Это отказ/восстановление одного overlay-линка, без проверки переключения на резервный маршрут или физической независимости управления. + +Cleanup удаляет адреса → VRF → GRE через тот же executor; исходные fingerprints GRE/VRF/IPv4 и IPv6 addresses/routes/routing tables сравниваются на обоих CHR. Не меняются публичные интерфейсы, firewall или глобальные IPv6-настройки. Уникальный маркер и исходные fingerprints сохраняются до первой внешней записи, результаты проб — по мере выполнения. Для восстановления прерванного запуска есть явный `MMAPP_TEST_CHR_OVERLAY_CLEANUP_MARKER=mcp-test-xxxxxxxx`: сверяются точные имена, comment, адреса, состав VRF и оба GRE endpoint; неизвестные зависимости останавливают очистку. + +Адресные/интерфейсные планы учитывают GRE, WireGuard и VXLAN и включают ссылки на родителей в проверку защит. Новая VRF создаётся перед первой существующей записью с сохранённым `placeBeforeId` в плане; порядок включён в fingerprint и проверяется после записи. Изменение порядка после подтверждения блокирует запись, проигнорированный устройством порядок даёт `partial`. Влияние порядка описано в [документации RouterOS VRF](https://help.mikrotik.com/docs/spaces/ROS/pages/328206/Virtual%2BRouting%2Band%2BForwarding%2B-%2BVRF). Автоматические dynamic IPv6 link-local адреса исключены из снимков изменяемой конфигурации: их появление/исчезновение при отключении собственного GRE не считается внешним редактированием и не блокирует обратный план. Явные link-local и остальные адреса остаются в снимках, список адресов по-прежнему показывает и автоматические записи. + +Контрольная проба сохраняет `packetCounts`, когда получены корректные итоговые счётчики трёх запрошенных пакетов. Подтверждённый отказ требует завершённых `sent=3/received=0` и согласованного результата ping. Неполные/переполненные/нечисловые счётчики, число пакетов вне запроса и противоречие между счётчиками и packet-loss не подтверждают отказ; эти случаи проверяются отдельными тестами. + +`MMAPP_TEST_CHR_OSPF=true` вместе с overlay-режимом включает [OSPF/BFD-приёмку](mcp-chr-ospf-acceptance.json). На собственном GRE создаются OSPFv2 и OSPFv3 instances/areas/templates и BFD-правила только для двух точных peer-адресов `/32` и `/128` в тестовой VRF. Пройдены четыре Full/Up соседства, реальные ping-измерения, cost 30→10 отдельно для IPv4/IPv6, сохранение cost другого семейства и обратное восстановление. Удаление выполняется в порядке templates → areas → instances → BFD rules; проверены fingerprints, затем пройдены прежние overlay fault/recovery и полная очистка. Это двухузловая приёмка cost и соседств, без доказательства глобального оптимума или переключения между независимыми физическими путями. + +OSPF-оптимизатор сначала ограничивает templates по family/instance/VRF и связывает Full neighbor с тем же instance. При включённом use-bfd требуется единственная Up-сессия с совпадающими адресом соседа, интерфейсом и VRF; ID и наблюдаемые адресные атрибуты сверяются до и после записи, а также в обратном cost-плане. Неизвестный use-bfd читается через native `/get`, без предположения false. Отсутствующее/двусмысленное BFD-доказательство останавливает подготовку. BFD-конфигурации ограничены явными интерфейсами, одним peer `/32` или `/128`, интервалами 1–60 секунд и multiplier 2–50; произвольного RouterOS script/command или ключей аутентификации эти схемы не принимают. Основания: [OSPF instances](https://manual.mikrotik.com/docs/cli-reference/routing/ospf/instance/), [BFD](https://help.mikrotik.com/docs/spaces/ROS/pages/191299691/BFD). + +`mm_ospf_catalog_get` вызывает тот же catalog service/parser, что `/api/ospf/catalog`, с предварительным ограничением серверов по эффективному подключению/grant. За обращение опрашивается не больше 10 серверов; серверы и пять коллекций имеют отдельную пагинацию, failures/incomplete остаются явными. Интеграция проверяет ровно семь native GET к одному разрешённому устройству, отсутствие запросов к скрытому серверу, пагинацию и ошибку опроса. Сервисные чтения legacy OSPF теперь покрывает отдельный `mm_ospf_state_get`, BFD — `mm_bfd_sessions_get`; optimize/preview покрывают отдельные `mm_ospf_application_optimize_plan` и `mm_ospf_application_preview`. Маскирование результатов дополнено OSPF auth-key и BGP TCP MD5 ключами, включая текстовые сообщения. + +`MMAPP_TEST_CHR_ECMP=true` вместе с overlay-режимом запускает [ECMP-приёмку](mcp-chr-ecmp-acceptance.json): два адресных next-hop через собственный GRE и отдельный loopback peer в тестовой VRF. Для IPv4/IPv6 подтверждены переход distance 10/20→10/10 с обоими active, routed ping/traceroute и возврат 10/20 обратным планом при сохранении связности. Глобальная hash-policy не менялась; распределение потоков и независимость физических путей не проверены. Очистка вернула baseline bridge/VRF/адресов/маршрутов, затем прошли общий overlay fault/recovery и удаление контура. Интеграция отдельно проверяет исчезновение/подмену immediate-gw перед записью, потерю resolved после записи, отсутствие retry и защиты таблицы/IP settings. VRF alias использует общий native prepare, включая защиту удаляемого прежнего интерфейса; ECMP hash-policy, RPF и прямой IP settings план учитывают одинаковую singleton-защиту. + +`MMAPP_TEST_CHR_BGP=true` вместе с overlay-режимом запускает [BGP-приёмку](mcp-chr-bgp-acceptance.json). Создаются только собственные BGP instances и connections с частными ASN, точными локальным/remote адресами и явно заданными VRF/routing table. Пройдены установление двух сессий, согласование IPv4/IPv6 AFI, административное отключение одной connection и восстановление обратным планом. Конфигурация instances/connections/routes восстановлена; активных тестовых сессий нет. Исторический readonly session cache принудительно не очищается. Экспорт/import префиксов, Community и BGP failover этим тестом не приняты. RouterOS 7.24.5 может не возвращать `vrf` в session: в отчёте сохраняется null, а контекст подтверждается прочитанными instance и connection. Новые connection-планы требуют явного instance, ASN и точных peer/local IP одного семейства; широкие динамические listeners и auth-ключи схемой не принимаются. Изменение ASN/Router ID/disabled instance включает peer-зависимости в проверки защит. + +`mm_bgp_catalog_get` использует общий BGP catalog poll/parser приложения, эффективную область серверов и ту же двухуровневую пагинацию. Тест подтверждает один GET к разрешённому серверу, отсутствие чужого опроса/метаданных и явный failure вместо успешного пустого каталога. + +Нативные планы IPv4/IPv6-маршрутов, routing rules и BGP connections включают снимок таблиц и защиты используемых таблиц. Отсутствующая/отключённая таблица блокирует подготовку; удаление, переименование или отключение таблицы блокируют обнаруженные ссылки маршрутов, rules, VRF, OSPF instances и BGP connections. Эти зависимости повторно сверяются перед записью. Отдельная интеграция проверяет добавление ссылки уже после подтверждения плана. Для прерванной приёмки оператор может явно передать `MMAPP_TEST_CHR_CLEANUP_MARKER=mcp-test-xxxxxxxx`: удаляется только точно указанная пустая non-FIB таблица без маршрутов и routing rules; обхода общего executor нет. + +Полная backend-регрессия после общих guards и каталогов проб прошла все профили; SQL/live logs на 5000 строк тоже зелёный. Некоторые старые SQL-ветки требуют собственного `MMAPP_OWN_TEST_DB` с особыми именами и без него явно пропускаются. Traffic-volume тест исправлен с учётом неизвестного membership в грубом bucket; алгоритм расчёта не менялся. Ранее пройдена миллионная проверка логов: вставка 19,3 с, история + summary 2,1 с на локальном стенде. + +Восстановление БД через MCP реализовано с загрузкой архива человеком, отдельным подтверждением, эксклюзивным исполнителем и квитанцией вне PostgreSQL. Пройден настоящий pg_dump/pg_restore временной БД: исходная операция исчезает, квитанция сохраняется, старый токен отзывается, внешний отзыв JWT переживает старый дамп, повреждённый архив не применяется и повторный запуск не пишет данные. Ошибки журнала/миграций/перезапуска после подтверждённого restore — отдельные предупреждения, без retry. + +`mm_speed_test_runs_get` читает историю скорости с SQL-ограничением обоих участников эффективной областью до пагинации и разбора метрик. Ноль и неизвестность сохраняются раздельно. `mm_ping_source_interfaces_get` и `mm_speed_test_interfaces_get` используют общие REST-сервисы: подтверждённые running/disabled, ограниченное чтение отсутствующего disabled через native `/get`, отдельный список неизвестных интерфейсов и статус пригодности IPv4-адресов. Каталоги не запускают нагрузочные пробы. `mm_ping_probe_visibility_plan` меняет только отображение Ping-пробы после человеческого подтверждения, проверки серверной принадлежности, защит, fingerprint и read-back. + +`mm_traffic_live_get` использует общий с REST `readTrafficLiveSample`: один интерфейс либо полная сумма активных интерфейсов без loopback, от одного до трёх срезов с интервалом не меньше секунды и общим дедлайном 30 секунд. Это RX/TX интерфейсов роутера, а не оценка пропускной способности канала. Перед каждым native-запросом и выдачей результата повторно проверяются credential/grant, политика и конфигурация источника. Неполная сумма, ошибка авторизации или неизвестные flags не превращаются в ноль; при смене источника/отзыве дальнейший опрос прекращается. SQL-интеграция проверяет полный/частичный ответ, точный ноль, один интерфейс, bounds, отмену, серверную область, отключение, отзыв и дрейф. На обоих CHR подтверждён live-срез выбранного интерфейса; агрегат по всем интерфейсам отдельно этим стендовым отчётом не принимается. + +Проверка подключения в web-разделе MCP выполняет официальный SDK handshake `initialize` и `tools/list` через фиксированный `MCP_PUBLIC_URL`. Для этого создаётся отдельный access-токен со сроком до 20 секунд и пустым grant: операции и серверы недоступны. Служебные credential/grant удаляются после успеха и ошибки, просроченные остатки восстанавливаются при следующей проверке этого подключения; повтор разрешён через 30 секунд. Дедлайн HTTP/тела ответа — 8 секунд, размер — 1 MiB, redirects запрещены. Успех требует пустого списка инструментов. Ответ `protocolProbe` отличает passed/failed/skipped и явно содержит `clientCredentialsVerified: false`: проверяется серверный транспорт, а не настройки конкретного внешнего клиента. SQL-интеграция проверила настоящий HTTP handshake, отказ операции пустому grant, очистку при успехе/исключении и cooldown; отдельный HTTP-профиль — deadline, malformed/oversize и redirect. + +`mm_evobgp_connection_check` и REST-проверка используют общий ограниченный GET `/v1/modules?limit=1`: до 1 MiB за 15 секунд, без redirects, с проверкой схемы items. MCP требует полный эффективный каталог серверов до HTTP и принимает сохранённые credentials либо owner/destination-bound `apiKeyRef` для чернового URL. Ключ не возвращается в ответе и не пересылается на другой URL из сохранённых настроек. Проверка не сохраняет настройки, не возвращает upstream items и не утверждает работоспособность BGP/каталога. Изменение сохранённой конфигурации во время проверки отвергает результат. SQL-приёмка подтверждает scope, чужой/неподходящий secret reference, отсутствие записи, маскирование отражённого ключа, malformed payload и redirect. + +После этого блока повторно выполнен `test:mcp:pg:all` на собственном временном кластере с настоящими client tools: все backend-профили и SQL/live logs завершены, `Regression failed profiles: none`, pg_dump/pg_restore и новая миграция readiness проверены. Backend build, frontend TypeScript и ESLint изменённых модулей прошли. Повторно пройдены девять web E2E; отдельная дополненная проверка подтверждает, что UI отображает `protocolHandshake` и `clientCredentialsVerified: false`. Read-only CHR-отчёт обновлён 18 октября, 15:26 UTC; конфигурационных операций и дрейфа baseline нет. Эти результаты не закрывают 82 pending REST-адаптер и отдельные сетевые критерии полного плана. + +`mm_ospf_state_get` использует общий с шестью REST-чтениями сервис состояния и вынесенные модели OSPF/BFD. Возвращаются отдельные страницы neighbors/interfaces/instances/areas/BFD/routes каждого разрешённого сервера. Native ID обязателен до объединения IPv4/IPv6, дубликаты и недостоверные строки дают явный failure; отсутствующие данные не становятся cost=10, OSPFv2, нулевым счётчиком или BFD Down. Area ID и instance связываются по подтверждённым именам/ID, не по порядковому номеру; неоднозначная area остаётся неизвестной. `mm_bfd_sessions_get` выполняет только чтение BFD, без опроса остальных OSPF-ресурсов. Legacy REST сохраняет совместимые значения по умолчанию в своей проекции; MCP использует nullable observed-проекцию того же чтения. Интеграция подтверждает семь GET для OSPF и один для BFD, предварительное ограничение серверов, пагинацию, disabled и ошибки; отдельные проверки моделей сохраняют известный ноль и исключают desired interval из actual interval. + +`mm_firewall_state_get` использует общий `listFirewallAll` с выбранными серверами до native-опроса. Читаются восемь IPv4/IPv6 таблиц и два address-list; rules/addressLists имеют отдельные страницы, ошибки конкретных источников остаются в failures. Enabled/disabled/dynamic/log, protocol и packets не подменяются legacy defaults: неизвестные поля возвращаются как null, известный ноль сохраняется. Отдельные matcher-поля сохраняют оба интерфейса и различают адрес/список, source/destination port. Подсчёт неизвестных flags/counters не означает, что правила проверены на реальных пакетах: `packetMatchingVerified` всегда false. Недостоверные ID/обязательные поля/дубликаты отвергаются по таблице. Observed revision сохраняется только при полном опросе и подтверждённых disabled/dynamic flags; неизвестные flags или partial не превращаются в готовый снимок. SQL проверил ровно десять scoped GET, paging, disabled/foreign, nullable flags/known zero, маскирование лишних полей и отсутствие предположительных ревизий. Сервисные планы изменения/восстановления firewall остаются отдельными незавершёнными пунктами. + +После OSPF/BFD повторно прошёл полный `test:mcp:pg:all`, включая SQL/live logs и реальный restore (`Regression failed profiles: none`). После firewall отдельно повторены MCP unit/integration с PostgreSQL, профиль live-maps с legacy REST, backend build и ESLint изменённых модулей. Web в этом блоке не менялся; девять E2E и отдельная проверка readiness относятся к предыдущему блоку. Последняя read-only CHR-приёмка завершена 18 октября, 15:49 UTC, без конфигурационных операций и дрейфа baseline. + +`mm_firewall_rule_move_plan` принимает `serverId` и `move: {family, table, rosId, destinationRosId?}`. Отсутствие destination означает конец таблицы; перенос перед собой или уже достигнутая позиция не создают план. Используется общий с REST `moveFirewallRule`: фиксированные пути IPv4/IPv6 filter/nat/mangle/raw, конкретные существующие native ID, подтверждённо статические source/destination и до 1000 правил в снимке. Omitted dynamic проверяется через native `/get`; неподдерживаемый/неизвестный ответ блокирует изменение. Сохраняется точный порядок и все нативные matcher-поля, включая поля вне UI-формы; packets/bytes исключаются из fingerprint. Изменение любого matcher или порядка после approval блокирует запись. Защиты всех строк таблицы и серверных зависимостей проверяются до исполнения, бюджет резервирует команду и ревизию. После POST сверяются порядок и полная конфигурация, включая идентичность источника; проигнорированная команда — partial, потерянный ответ — unknown, без повторной отправки. Статус сохранения ревизии возвращается отдельно и не подменяет проверку порядка. Автоматический inverse не создаётся: возврат готовится новым планом по свежему состоянию. + +Общая блокировка приложения и сервера охватывает чтение, команду, read-back и ревизию также для прямого web-вызова. Источник повторно выбирается из БД под блокировкой; stale credentials/host/disabled не используются. SQL-проверка удерживает lease другим исполнителем и подтверждает отказ до POST, а также отказ до сети при изменённой конфигурации источника. REST отклоняет selectors со списком ID и перемещение перед собой до lookup устройства. + +SQL/имитатор проверили scope, защиты соседней строки, дрейф дополнительного matcher, рост счётчиков между approval/apply, точный порядок, сохранение ревизии, no-op/dynamic, partial и idempotency. [CHR-приёмка перемещения](mcp-chr-firewall-move-acceptance.json) на двух RouterOS 7.24.5 завершена 18 октября, 16:05 UTC. Стенд требует пустую filter-таблицу и создаёт ровно три собственных отключённых правила в уникальной непривязанной цепочке. Проверены перемещение, отдельный план возврата порядка, отсутствие повторной POST-команды и удаление fixtures; исходные fingerprints восьми таблиц firewall восстановлены. Непустой общий MCP-каталог подтвердил flags false и настоящий ноль счётчика. Обработка реальных пакетов и влияние порядка действующих правил не проверялись. Запуск: `node backend/scripts/test-mcp-postgres.mjs --chr-firewall-move` с теми же переменными подключения и скрытым вводом пароля; перед подтверждением move проверяется принадлежность всей таблицы только этим fixtures. + +После move прошёл полный `test:mcp:pg:all`, все backend-профили, SQL/live logs и настоящий pg_dump/pg_restore (`Regression failed profiles: none`). Финальное расширение общей блокировки дополнительно проверено повторным MCP SQL/restore, live-maps и сборкой/ESLint; unit проверяет неполные обязательные поля и отсутствие отправки при неподтверждённой static identity. UI-компоненты не менялись. + +`mm_wireguard_state_get`, `mm_ipsec_state_get` и `mm_vxlan_state_get` используют общие REST polling/parser по эффективной серверной области до HTTP. За сервер выполняются соответственно четыре, восемь и два GET, без фонового collector и сетевых изменений. Пустая область не превращается в глобальный запрос. Disabled/foreign проверяются до опроса; недостоверные native ID/дубликаты, неизвестный родитель WG peer, ошибка любого обязательного источника дают явный failure. В WireGuard родитель peer/адреса разрешается как по имени, так и по native ID. + +WireGuard возвращает nullable flags, mtu/listenPort и счётчики: отсутствие bytes не равно нулю, неизвестный running/disabled не становится Down. `addressStateVerified` отдельно показывает подтверждение состояния адреса; адрес не объявляется доказательством связности. Есть отдельные страницы interfaces, peers и allowedIps (`offset/limit`, `peerOffset/peerLimit`, `allowedOffset/allowedLimit`). Приватные ключи и preshared keys не выдаются даже с network:write; ключи, отражённые источником в name/comment, маскируются до результата/аудита. Параметра includePrivateKey в MCP-схеме нет; конфигурационные secret exports требуют отдельного человеческого сценария. + +IPsec сохраняет бизнес-каталог IKEv2 peers/сертификатов/клиентов приложения. Flags passive/disabled/trusted и наличие key pair могут быть null. Неоднозначное сопоставление активной сессии или неизвестное native state не выдаётся за offline; подтверждённая established-сессия остаётся известной даже при неизвестном состоянии другой сессии. Возвращаются `clientsOnlineConfirmed`, `clientsSessionUnknown` и `unknownSessionStates`. Настроенный сервер/сертификаты подтверждают только configuration-presence, не data plane. Дата сертификата передаётся как `nativeExpiresAt` с `expiryTimestampVerified:false`; за проверенной датой/часовым поясом следует обращаться к отдельному certificate-state инструменту. Clients имеют offset/limit, peers/certs — detailOffset/detailLimit. PSK и отражённые секреты исключаются. WG/IPsec чтение явно сообщает `configurationHistoryUpdated:false`. + +VXLAN сохраняет строгую проверку VNI, destination port, flags, ARP/learning и принадлежности статических VTEP. Страницы туннелей и VTEP независимы (`offset/limit`, `vtepOffset/vtepLimit`). Legacy source-port placeholder не передаётся MCP как наблюдаемый UDP-порт; используется подтверждённый `dstPort`. RouterOS 7.24.5 на непустом отключённом VXLAN не возвращает l2mtu: общий сервис и web-схема теперь принимают `l2mtu:null`, без выдуманного MTU. Непустое некорректное значение по-прежнему считается ошибкой. Экспорт не добавляет l2mtu/предполагаемый MTU. Ни один из каталогов не объявляет наличие интерфейса/сессии проверкой forwarding. + +SQL/имитатор подтвердили точное число запросов к одному разрешённому серверу, вложенные страницы, zero/unknown, static address state, PSK/private-key reflection, IKEv2 session ambiguity и неизвестный state, отказ источников, дубликаты и отсутствие чужого/disabled опроса. Read-only [отчёт CHR](mcp-chr-catalog-acceptance.json) обновлён 19 октября, 01:56 UTC: 20 видов чтения, без мутаций, с 15 неизменными fingerprints на каждом CHR. В этом отчёте VPN-каталоги пустые; непустые конфигурационные проверки выделены отдельно. + +[Непустая VPN-приёмка](mcp-chr-vpn-catalog-acceptance.json) на обоих CHR завершена 19 октября, 01:47 UTC. Стенд отказывается от существующих WG/VXLAN/IPsec peer конфигураций, создаёт только собственные отключённые объекты с уникальным marker и точечным passive IPsec peer без сертификатов/identities/SA. Подтверждены реальные DTO, отсутствие сгенерированного приватного WG-ключа в ответе и аудите, destination port VXLAN и null для отсутствующего native l2mtu. После теста все три объекта удалены через проверенные планы; fingerprints WG/VXLAN/IPsec peer и IPv4/IPv6 адресов восстановлены. VPN-трафик, peers/SA и end-to-end forwarding этим конфигурационным тестом не принимаются. Запуск: `node backend/scripts/test-mcp-postgres.mjs --chr-vpn-catalog` с теми же allowlist/tunnel переменными и скрытым вводом пароля. + +Полная регрессия после VPN-каталогов прошла все backend-профили, SQL/live logs и настоящий restore (`Regression failed profiles: none`). В negative restore-тесте ожидание terminal receipt согласовано с положительным сценарием: до 60 секунд вместо 5; конечный blocked, неизменность БД/поколения и запрет повтора остались обязательными. Последние allowed-prefix страницы и обработка l2mtu дополнительно проверены MCP SQL, моделями, frontend TypeScript и сборкой/ESLint; web export/schema принимают null и не добавляют выдуманный MTU. + +Основания сетевых проверок: [IP Routing](https://help.mikrotik.com/docs/spaces/ROS/pages/328084/IP%20Routing), [VRF](https://help.mikrotik.com/docs/spaces/ROS/pages/328206/Virtual%2BRouting%2Band%2BForwarding%2B-%2BVRF), [Policy Routing](https://help.mikrotik.com/docs/spaces/ROS/pages/59965508/Policy%2BRouting). + + +`mm_gre_state_get` закрывает чтение REST `/api/gre/tunnels` через общий `fetchGreNativeState`: ровно три GET к GRE и IPv4/IPv6-адресам на выбранный доступный сервер. Пустая/disabled область не опрашивается. Native identity, уникальность имён, обязательные адресные источники и неоднозначные name/native-ID привязки проверяются до проекции. MTU, DSCP, флаги и keepalive возвращаются nullable; явный ноль сохраняется. Адреса имеют семейство, native ID, disabled/dynamic/invalid и отдельный `configurationVerified`; неизвестное состояние не выдаётся за подтверждённый адрес. Локальный/удалённый внутренние адреса из комментария выделены как непроверенные метаданные. Состояние running не доказывает forwarding. + +GRE имеет независимые страницы серверов, туннелей и адресов (`serverOffset/serverLimit`, `offset/limit`, `addressOffset/addressLimit`), детерминированную сортировку и признаки неполной выдачи. PSK не выдаётся; известный PSK, отражённый в обычном поле, маскируется до аудита. Ошибка любого из трёх источников даёт безопасный failure вместо успешного пустого каталога. MCP-чтение не сохраняет secret-bearing конфигурационную историю, что явно отражено в `configurationHistoryUpdated:false`; существующий REST сохраняет прежний сценарий истории. + +SQL/имитатор проверил три scoped GET, nested paging, неизвестные поля, отказы каждого источника, дубликаты, запрет чужих/disabled серверов, отсутствие записи ревизии и PSK в результатах/аудите. [Read-only CHR](mcp-chr-catalog-acceptance.json) обновлён 19 октября, 01:56 UTC: 20 видов чтения на каждом CHR и 15 неизменных fingerprints. [Непустая приёмка](mcp-chr-vpn-catalog-acceptance.json) расширена собственным отключённым GRE с MTU 1400 и keepalive 10s/10: после чтения GRE/WG/VXLAN/IPsec все fixtures удалены, шесть fingerprints восстановлены на каждом CHR. Это конфигурационная приёмка, передача трафика этим тестом не проверялась. Текущее покрытие: 110 mediated, 62 excluded и 82 pending REST-маршрутов. + +После GRE-блока повторно выполнен полный `test:mcp:pg:all -- --restore` с явным `MMAPP_PG_CLIENT_BIN`: все backend-профили и SQL/live logs прошли, итог `Regression failed profiles: none`. Настоящие pg_dump/pg_restore и negative terminal receipt проверены на собственном временном кластере. GRE-модели, config-sync, MCP unit/SQL, финальный backend TypeScript и ESLint прошли. Оба CHR очищены, SSH-туннель закрыт; рабочая БД и production-развёртывание не затрагивались. + + +`mm_firewall_rule_form_create_plan`, `mm_firewall_rule_form_update_plan`, `mm_firewall_rule_form_toggle_plan`, `mm_firewall_rule_form_delete_plan` закрывают POST/PUT/PATCH/DELETE `/api/firewall/rules` через общий сервис `writeFirewallRule`. Вход: `serverId`, `rule: {operation, family: ip|ip6, table: filter|nat|mangle|raw, rosId?, fields?, disabled?}`; operation фиксирован выбранным инструментом. Для create/update fields соответствуют форме REST, включая TLS host и L7 protocol; update требует chain/action. Пустые строки и protocol=all сохраняют прежнее поведение формы — они не очищают matcher-поле на устройстве. Отдельные native-инструменты имеют собственные схемы и не объявляются эквивалентом этой формы. + +Каждый план требует человека и сохраняет полный порядок/конфигурацию выбранной таблицы без packets/bytes. Update/toggle/delete требуют настоящего ID и подтверждения static (при отсутствующем флаге — native get). Ошибка/unknown не разрешает запись. Область и все зарегистрированные защиты сервера проверяются консервативно: Firewall может влиять на управление и зависимости за пределами одной строки. Тот же сервис в REST удерживает общую application/server блокировку от свежего lookup источника до read-back и ревизии. Дрейф source/host/credentials, порядка или непрозрачного matcher блокирует команду; runtime counters — нет. + +После create подтверждаются ровно один новый ID, добавление в конец таблицы и все заданные поля; update/toggle сохраняют остальные native matcher-поля и порядок; delete подтверждает отсутствие ровно выбранного ID. Bool yes/no и true/false нормализуются при проверке. Потерянный ответ не повторяется, проигнорированная запись становится partial. Ревизия записывается только после подтверждённого read-back и полного снимка; `revisionRecorded` отдельно показывает результат, неизвестные flags не превращаются в фиктивную ревизию. Прямой REST при неподтверждённой записи возвращает needsReview. + +SQL/имитатор проверил четыре MCP-плана и четыре настоящих REST-обработчика, TLS/L7, защиты зависимостей, чужую область, stale matcher/source, конкурирующую блокировку, native static, partial, ревизии и idempotency; некорректные списки/path вместо ID REST отклоняет до работы с устройством. [Приёмка Firewall CRUD](mcp-chr-firewall-write-acceptance.json) пройдена на обоих CHR 7.24.5 для IPv4 и IPv6: собственная непривязанная chain, create/edit/toggle/disable/delete, отсутствие повторных команд и очистка с восстановлением восьми fingerprints на каждом CHR. Проверка реального packet matching этим сценариям не приписывается. Запуск: `node backend/scripts/test-mcp-postgres.mjs --chr-firewall-write` с разрешёнными адресами CHR, tunnel ports и скрытым вводом пароля. Покрытие после Firewall rule CRUD: 114 mediated, 62 excluded, 86 pending. + +После общего Firewall CRUD сервиса полный `test:mcp:pg:all -- --restore` завершился успешно: `Regression failed profiles: none`, все запускаемые backend-профили и SQL/live logs прошли. Проверены настоящие pg_dump/pg_restore временной БД, unit/service и прямые REST-сценарии, source drift и конкурирующий lease. Финальный backend TypeScript, ESLint и diff-check прошли. CHR fixtures удалены, baseline восстановлен, SSH-туннель закрыт. На этом этапе address-list CRUD и firewall revision restore ещё оставались pending. + + +`mm_firewall_address_list_create_plan`, `mm_firewall_address_list_update_plan`, `mm_firewall_address_list_toggle_plan`, `mm_firewall_address_list_delete_plan` закрывают четыре REST-операции `/api/firewall/address-lists` через общий `writeFirewallAddress`. Вход: `serverId` и `address: {operation, family: ip|ip6, rosId?, fields?, disabled?}`. Create/update используют fields формы REST (list, address, comment, timeout, disabled), update требует list/address. Непустые переданные значения меняются; пустые строки не очищают существующие поля. Снимок ограничен 1000 строками, проверка совпадающих кандидатов для create — 50 строками. Проверяются native ID/известное происхождение, общий серверный scope, консервативные защиты всех зависимостей, application/server locks и свежий источник перед командой. Планы требуют человека. + +Timeout в каталоге — оставшееся время. Stable fingerprint сохраняет класс срока, ID, list/address, flags, creation-time и остальные native-поля; естественный countdown не ломает план. Для выбранной временной записи отдельно сохраняется окно абсолютного истечения, ограниченное временем запроса и секундной точностью native-значения. Срок повторно сверяется до intent и перед отправкой, а после записи — при read-back/verify. Продление/сброс вне окна блокирует старый план. Это интервальная проверка, не точное измерение миллисекунд: окна могут перекрываться в пределах точности источника/задержки запроса. Планы выбранной временной записи истекают до её нижней границы срока; слишком близкое истечение блокирует подготовку. + +IPv4 host/CIDR/range и IPv6 host/CIDR сравниваются по адресной идентичности, без DNS-запросов и без смешивания mapped IPv6 с IPv4. Подтверждённая dynamic-запись не объявляется статической: удаление временных записей принимается при подтверждённом ID/сроке, а доступность их edit/toggle определяется RouterOS без повторной отправки. Ошибка/проигнорированная команда не превращается в applied; неоднозначные DNS-производные записи требуют сверки. Packet matching и DNS forwarding не проверяются. Временные записи хранятся в RAM; revision-снимок по прежним правилам хранит только постоянную конфигурацию, а `revisionRecorded` не означает сохранение timed entry. Основание: [RouterOS Address-lists](https://help.mikrotik.com/docs/spaces/ROS/pages/130220135/Address-lists). + +SQL/имитатор проверил четыре MCP-плана и четыре прямых REST-обработчика: канонизацию host, scoped/protected dependent rule, stale поля, partial/idempotency, finite create/delete, разрешённый countdown и запрет продления за пределами expiry window до команды. [CHR-приёмка address-list](mcp-chr-address-list-acceptance.json), 19 октября 02:25 UTC: оба CHR, IPv4/IPv6 permanent CRUD, IPv4 range и IPv6 canonical CIDR, timed entry с native dynamic=true/timeout 1m58s–1m59s и delete после естественного уменьшения. Fixtures удалены; восемь firewall и два address-list fingerprints восстановлены на каждом CHR. Запуск: `node backend/scripts/test-mcp-postgres.mjs --chr-address-list` с теми же разрешёнными hosts/tunnel ports и скрытым вводом пароля. Покрытие после address-list CRUD: 118 mediated, 62 excluded, 82 pending. + +После address-list блока полный `test:mcp:pg:all -- --restore` завершён с `Regression failed profiles: none`: все запускаемые backend-профили, SQL/live logs и настоящий pg_dump/pg_restore изолированной БД прошли. Отдельные модели expiry/canonical address, MCP/REST integration, backend build/TypeScript, ESLint и diff-check прошли. CHR очищены, десять baseline fingerprints совпали на каждом устройстве, SSH-туннель закрыт. Весь план ещё не закрыт: 82 REST-адаптера, включая firewall revision restore, остаются открытыми. + + +`mm_firewall_revision_restore_plan` закрывает POST `/api/firewall/revisions/:id/restore` через общий REST/MCP `restoreFirewallRevision`. Аргументы: `serverId`, `revisionId`. Проверяются scoped revision lookup, её persisted fingerprint, строгая каноническая схема (явные rules/addressLists, включая намеренно пустые), до 50 желаемых и 100 текущих записей. Повреждённая/чужая ревизия не превращается в очистку. Новый снимок сохраняет native matcher/body-поля вне старой формы; ID, runtime flags/counters/creation-time и дублирующие modeled disabled/log в native payload не записываются. Неизвестный log исключает подтверждённую observed-ревизию; strict restore-reader при необходимости читает default flags через native get. Старые ревизии восстанавливают только сохранённую форму и явно показывают эту границу. + +Исправлены порядок и multiplicity: восстанавливается весь статический набор IPv4/IPv6 filter/nat/mangle/raw и постоянных address-list, а не множество ключей. Native ID меняются. Динамические записи не удаляются, сохраняют взаимный порядок и после восстановления предшествуют новому статическому набору; их появление/исчезновение останавливает дальнейшие стадии. Countdown неперезаписываемых динамических address-list не считается дрейфом; их удаление/другие изменения конфигурации считаются. RAM/time-limited entries из ревизии не создаются. + +Сначала создаются все новые записи с disabled=true; после каждого PUT проверяются новый ID, native invalid=false для правил, все переданные matcher-поля и неизменность прежних строк/порядка. Лишь после полной подготовки удаляются старые статические ID, затем включаются нужные новые записи; каждая DELETE/PATCH проверяется до следующей команды. Неподтверждённая подготовка не удаляет старый набор. Переключение не атомарно и может затронуть управление/соединения; обязательны человеческий план и все зарегистрированные защиты сервера. Общий application/server lease охватывает свежий source/revision lookup, proof, команды, read-back и rollback-ревизию. В бюджет включены все stage PUT, DELETE, enable PATCH и сохранение ревизии. Результат различает planned commandCount, acknowledgedCommands и verified completedCommands. Потерянный ответ не повторяется; partial/unknown требуют сверки, автоматического inverse нет. + +SQL/имитатор и прямой REST подтвердили scope, corrupt/stale revision/opaque matcher, защиты, бюджет, дубликаты/порядок, dynamic preservation, disabled staging, stopped ignored/lost writes, отзыв между стадиями, точный read-back/ревизию и idempotency. Activation отдельно проверяется через настоящий локальный HTTP/SQL. [CHR Firewall restore](mcp-chr-firewall-restore-acceptance.json) завершён на двух RouterOS 7.24.5 19 октября, 02:57 UTC: шесть собственных отключённых правил (accept/accept/drop для IPv4 и IPv6) с native connection-mark, два постоянных списка, затем отдельный план пустой ревизии. Десять baseline fingerprints восстановлены на каждом CHR. Packet matching/активация действующего Firewall этим стендовым сценарием не принимаются; activation проверен моделями и HTTP/SQL. Временные неуспешные диагностические запуски не объявляются успехом; итоговый отчёт complete=true подтверждает очистку. + +Запуск: `node backend/scripts/test-mcp-postgres.mjs --chr-firewall-restore` с прежними hosts/tunnel ports и скрытым вводом пароля. Стенд требует пустой Firewall; он отказывается подтверждать чужие/активные строки. После сбоя теста предыдущие отключённые fixtures могут быть очищены только по свежему подтверждённому плану, точному previous marker и сохранённому пустому baseline; это отдельная стендовая очистка, не автоматическое восстановление пользовательской операции. Покрытие после Firewall restore: 119 mediated, 62 excluded, 81 pending. + +После Firewall restore выполнен полный `test:mcp:pg:all -- --restore`: все запускаемые backend-профили и SQL/live logs прошли, итог `Regression failed profiles: none`, настоящий pg_dump/pg_restore проверен. Финальные activation/counter/validity guards дополнительно проверены MCP HTTP/SQL и настоящим restore временной БД; invalid=false повторно принят на обоих CHR, отчёт обновлён 02:57 UTC. Entity/restore/polling модели, backend TypeScript, ESLint и diff-check прошли. Старый set-based planner заменён общим точным entry point; неизвестные flags не допускаются и прямым capture-helper. Все 12 конфигурационных REST-маршрутов Firewall имеют MCP-адаптеры. На момент этой проверки оставался 81 REST-сценарий и отдельная packet/сетевая приёмка полного плана. + +## NetFlow: экспортёры, клиенты и месячные отчёты + +`mm_netflow_exporters_get`, `mm_netflow_clients_get` и `mm_netflow_monthly_report_get` закрывают три GET-сценария `/api/traffic/flow/exporters`, `/clients`, `/monthly` через общие REST/MCP readers. Требуются `mm:traffic:read` и явно разрешённые серверы. Серверная страница ограничена десятью источниками, клиентская — 50 пользователями; страны, сервисы и ASN месячного отчёта имеют собственные страницы. История выключенных серверов доступна, но выключенный сбор не означает недоступность устройства. + +Область подключения, суженная временным grant, применяется до SQL-агрегации, разбора счётчиков и pending-строк. Пользователь со связью с любым скрытым сервером исключается целиком в SQL; его имя и скрытые привязки не попадают в Node или ответ. Отбор пользователя и проекция его разрешённых привязок выполняются одним SQL statement. Частичная серверная страница отмечается `completeSourceSet:false`. Приватные ключи, email и роли не входят в проекцию. + +Окно чтения фиксирует обе границы; будущие и старые строки не входят в выбранный период. MCP ограничивает объём до 50 тысяч сохранённых/ожидающих строк на серверную страницу, 1000 привязок на пользователя и 10 тысяч агрегатных ключей месячного отчёта. Превышение даёт ошибку, а не обрезанный итог. Legacy REST сохраняет прежнюю ширину отчёта. Счётчики проверяются до преобразования из bigint и после сложения: отрицательные, нецелые и небезопасные значения отвергаются. + +Суммы отражают сохранённые top rows по серверу/минуте, а не весь сетевой трафик. Дедупликация между интерфейсами и полнота покрытия не подтверждены. Клиентские значения относятся только к текущим named whole-interface ingress-привязкам: peer-specific связи не приписывают пользователю весь WireGuard-интерфейс, числовые ifIndex не получают неподтверждённого исторического имени. Для peer-only значений возвращается `null`; историческое владение интерфейсом не подтверждается. Средняя месячная скорость рассчитывается по полному календарному месяцу, включая ещё не наступившую часть. + +Кольцо экспортёра показывает до 60 номинальных двухсекундных слотов, рассчитанных из поступивших flow bytes. Возможны padding-нули, timestamps слотов не подтверждены; отсутствующее кольцо даёт `null`, а не выдуманный ноль. В клиентском ответе оно явно обозначено как контекст сервера, не индивидуальная скорость клиента. `availability:unknown`, `wireRateVerified:false` и `forwardingVerified:false` сохраняются. Сохранённая peer-запись не подтверждает конфигурацию экспортёра на RouterOS. Некорректная запись выбранного peer даёт явное `UNVERIFIED_SCHEMA`; чужой malformed peer/ring не ломает разрешённый отчёт. + +Модели и SQL/REST проверили скрытые/смешанные привязки, сужение grant, пустые страницы, историю отключённого сервера, будущие/pending строки, переполнение и предел группировки. Полный `test:mcp:pg:all -- --restore` прошёл со всеми запускаемыми профилями и SQL/live logs; последние проверки ring/peer отдельно прошли `test:mcp:pg -- --restore` с настоящим pg_dump/pg_restore. Чтения NetFlow не обращаются к RouterOS. Packet/forwarding-приёмка этого блока не заявляется. + +## Legacy-каталог контейнеров + +`GET /api/containers`, `GET /api/servers/:id/containers` и `mm_containers_state_get` используют общий collector `listContainersState`. MCP выбирает разрешённые включённые серверы до создания RouterOS-клиентов, сортирует строки по native ID до пагинации и показывает per-device failures/unsupported. Legacy REST сохраняет прежний envelope и ошибку 502 при неполном чтении; отсутствие пакета по-прежнему даёт пустой legacy-каталог, тогда как MCP явно возвращает `unsupported`. Значения env и свободная команда остаются скрытыми в MCP и аудите. + +Настоящие REST-обработчики и MCP проверены на одинаковом HTTP/SQL-стенде: идентичность объектов/статусов, неизвестный boot, различие legacy-secret и MCP-redaction, отказ env, отсутствие пакета, обратный порядок нативных строк и страница за концом каталога. Модели, loopback HTTP-профиль контейнеров, backend TypeScript и ESLint прошли. Это расширение каталога не подтверждает запуск/остановку контейнеров на CHR без установленного пакета. + +## Legacy-каталог сертификатов + +`GET /api/certificates`, повторное чтение `POST /api/certificates/refresh` и `mm_certificates_state_get` используют один источник: `/certificate` и проверку стабильного `gmt-offset` до/после native-дат. Серверная область применяется до опроса; пустой выбранный набор не превращается в глобальный. Malformed ответ теперь даёт явный failure вместо успешного пустого legacy-каталога. Refresh не меняет конфигурацию устройства и не выдаёт сертификат. + +Legacy DTO и его отображение дат сохранены. Более строгий MCP observed DTO отдельно сохраняет `null`/`unknown` при неподтверждённых сроках, статусе, key size или trust; он не наследует legacy fallback «valid» для отсутствующих свойств. Native/private key material не входит в оба DTO. Страницы MCP стабильны по ID, выборка за концом списка помечается неполной. HTTP/SQL проверил GET/refresh, совпадение ID/имён, фактический clock offset, отсутствие ключа, malformed failures и пустую область. Профиль сертификатов, NetFlow-модели, backend TypeScript, ESLint, MCP-профиль и SQL с настоящим pg_dump/restore прошли. + +## Сервисный каталог BGP-in + +`mm_bgp_filter_rules_get` закрывает `GET /api/filters/rules` через общий `readFilterRulesCatalog`. Требуются `mm:filters:read`, операция `routing.filters.catalog.read` и разрешённые серверы. `source:live` читает выбранные включённые устройства; `source:cache` ограничивает SQL выбранными ID и позволяет посмотреть сохранённые правила отключённого сервера. Серверные страницы и отдельные страницы business/native/GRE-коллекций ограничены общими лимитами MCP. Пустая выбранная страница не запускает глобальный запрос. + +Наблюдатель не заменяет кэш и не создаёт конфигурационную ревизию. Legacy live GET сохраняет прежнее обновление observed-кэша/истории, а запрос без serverId — чтение кэша. Ошибка или malformed live-ответ дают только кэш этого сервера с `live:false`, `stale:true`, явной ошибкой и `nativeRules:null`. Намеренный cache-read не доказывает свежесть: `cacheFreshnessVerified:false`. Некорректный serverId возвращает 400, отключённый сервер не опрашивается. + +Нативные правила сохраняют ID, относительный порядок внутри `bgp-in`, исходный текст и `enabled:null` при неизвестном флаге. Отсутствующие ID/chain/rule и дубли ID отвергаются. Business parser возвращает эвристические Community/gateway/blackhole-подсказки, включая извлечения из disabled/unknown строк; это не вычисленная действующая политика. Переходы между chain, доступность gateway, сохранённые recursive references, обмен префиксами и forwarding не подтверждаются. GRE-проекция содержит только ссылки/адреса; точные состояния и MTU доступны через `mm_gre_state_get`, синтетические legacy defaults в этот ответ не переносятся. + +SQL/REST проверил native порядок/флаги, pages, отказ чужого сервера до опроса, скрытый cache, отсутствие observer cache/revision writes, legacy refresh кэша, malformed fallback, отключённый источник и отсутствие ключей/PSK в ответе и аудите. Модели, config-sync, live-maps, MCP-профиль, backend TypeScript и ESLint прошли; SQL-приёмка завершилась настоящим pg_dump/pg_restore. Отдельные старые own-DB ветки config-sync без их переменных не объявляются выполненными. + +[Повторная CHR-приёмка](mcp-chr-catalog-acceptance.json) завершена 19 октября, 07:18 UTC: на каждом RouterOS 7.24.5 пройден 21 вид чтения и IPv4/IPv6 ECMP preflight, совпали 15 исходных fingerprints. Новый BGP-in каталог подтвердил live/device источник, нулевой native-набор и отсутствие записи filters-кэша/ревизий; непустые правила, порядок и disabled/unknown проверены SQL/имитатором. Контейнеры на этих CHR остаются `unsupported`. Конфигурационные команды не выполнялись; туннель закрыт. Обмен BGP-префиксами и запуск контейнеров этим чтением не принимаются. + +## GRE: сервисные create/update/delete + +`mm_gre_tunnel_create_plan`, `mm_gre_tunnel_update_plan` и `mm_gre_tunnel_delete_plan` закрывают POST/PATCH/DELETE `/api/gre/tunnels` через общий `mutateGreTunnel`. Нужны `mm:network:write`, разрешённая операция `network.gre.*`, сервер и подтверждение человеком. Создание принимает `fields` с именем/IPv4 outer endpoints и необязательным IPv4/IPv6 внутренним адресом; изменение и удаление адресуются по native `rosId` или однозначному имени. Переименование отдельно не выполняется. Условный «основной» адрес остаётся тем, который выбирает существующий GRE reader; остальные статические адреса сохраняются. + +Перед изменением проверяются источник подключения, GRE/адреса, интерфейсный inventory, bridge ports/VLAN/list membership, VRF, маршруты, BGP filters/connections, OSPF templates, BFD configuration, routing rules и серверные DB-привязки пользователей/filters. Удаление с обнаруженной зависимостью или динамическим адресом, кроме автоматического IPv6 link-local, отклоняется. Проверяются все зарегистрированные защиты этого сервера. Непрозрачные native-поля входят в proof; runtime flags/счётчики и автоматические IPv6 link-local адреса не объявляются самостоятельной конфигурацией. Межпроцессные application/server locks общие для REST и MCP. + +Создание/изменение сначала подготавливает GRE отключённым, затем меняет только запланированные статические адреса; требуемое включение выполняется последней командой. Удаление активного GRE сначала отключает его и затем удаляет адреса/интерфейс. Это не атомарное переключение, возможен перерыв связи. План включает от 1 до 50 native-команд; бюджет резервирует весь пакет и запись ревизии. Перед каждой командой проверяются свежий proof и действующий доступ; после команды — её точный эффект и неизменность остальных наблюдаемых объектов. Игнорируемая команда останавливает пакет как `partial`, потерянный ответ — как `unknown`; автоматического повтора или обратного плана нет. Обратное изменение готовится отдельно после сверки. + +При PATCH сохраняются нативная запись keepalive, вторичные адреса, скрытый необязательный PSK и непрозрачные свойства. Частичное изменение непроверенного таймера отклоняется. Пустые comment/localAddress/PSK могут очищаться явно; очистка текста комментария сохраняет remote inner IP, пока те метаданные не очищены отдельно. Remote inner IP — метаданные, он не создаёт маршрута или удалённого адреса. + +MCP не принимает сырой `ipsecSecret`: нужен `ipsecSecretRef` секрета владельца с точным purpose `gre-ipsec::` и тем же сервером; удаление ключа задаётся `clearIpsecSecret:true`. Одновременно задавать оба нельзя. Ключ не входит в MCP-результат или аудит. Если чувствительное свойство скрыто и не меняется, оно не заменяется пустой строкой. Для явной записи нужен подтверждённый read-back. На RouterOS 7.24.5 пустой строковый `get` даёт `[]`; это принимается как пустой PSK только после двух совпавших проверок локальной учётной записи/группы с `sensitive`. Без такого подтверждения пустой/скрытый ответ остаётся unknown. Явное включение FastPath при неизвестном PSK требует отдельной проверенной настройки ключа. Формат ответов/команд описан в [RouterOS REST API](https://manual.mikrotik.com/docs/developer-guides/rest-api/), свойство PSK и его побочные IPsec-объекты — в [GRE Properties](https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE). + +Receipt отдельно содержит `verified`, числа planned/acknowledged/completed команд, `afterFingerprint`, `revisionRecorded`/`revisionError`. Проверяется сохранённая конфигурация; `addressReadinessVerified:false` и `forwardingVerified:false` сохраняются. Статический адрес отключённого GRE может корректно иметь `invalid:true`: это не превращается в утверждение о работоспособности адреса, DAD, SA или канала. Сбой сохранения полной ревизии после подтверждённого изменения не превращается в повтор native-команд. + +SQL/реальный REST и модели подтвердили v4/v6 CRUD, сохранение secondary/opaque/PSK/timer, purpose/owner/server секрета, scope, бюджет/защиты, зависимости, дрейф, частичный результат, потерю ответа, idempotency и отзыв между командами. Полный `test:mcp:pg:all -- --restore` прошёл: все запускаемые backend-профили и SQL/live logs, настоящий pg_dump/pg_restore; последние PSK/readiness guards отдельно проверены моделями, MCP HTTP/SQL и `test:mcp:pg -- --restore`. В первом диагностическом полном прогоне restore был unconfirmed; новый изолированный полный прогон и последующая SQL/restore-проверка прошли, команды исходной операции не повторялись. Отдельные старые own-DB ветки без нужных переменных не объявляются выполненными. + +[CHR GRE](mcp-chr-gre-mutation-acceptance.json) обновлён 19 октября, 08:38 UTC: business CRUD и native aliases на двух RouterOS 7.24.5. Тест использует только собственные отключённые GRE и private benchmark/ULA адреса, включая явную пустую конфигурацию PSK как в web UI; меняет v4 на v6 с `advertise:false`, отдельно выполняет native rename/очистку comment/delete, удаляет fixtures и сравнивает восемь fingerprints. Очистка после неуспешного опыта — отдельные свежие подтверждённые native-планы по точному имени и известному ID текущего fixture, не повтор неизвестной операции. Непустой PSK/SA и передача пакетов остаются отдельной приёмкой. + +`mm_gre_update_plan`/`mm_gre_delete_plan` сохраняют native-смысл одной команды над одним интерфейсом и используют `network.gre.native.*` исполнители. Для rename/delete должны отсутствовать обнаруженные пользовательские/filters ссылки, настроенные адреса и native зависимости. Удаление GRE не даёт неявного права удалить IPv4/IPv6 адреса; для пакетного изменения есть отдельные business-планы. Обычный scalar update связанного интерфейса допускается только с прежним подтверждением и проверкой защит. Source proof включает выбранные серверные DB-привязки и видимую native-конфигурацию, а также восемь IPv4/IPv6 Firewall таблиц. Порядок VRF/routing rules/filter/BFD/Firewall сохраняется; counters исключены, неподтверждённые значения не схлопываются в одинаковый null. Пустые comment и default local-address нормализуются отдельно; отсутствующий PSK не превращается в пустой ключ. + +Стандартные aliases резервируют одну native-команду и запись ревизии. Прямой `executeRosStep` для GRE rename/delete, включая старые inverse/ROS steps, проходит тот же guard и точный read-back, сохраняя прежнюю стоимость одной native-команды без дополнительной записи истории. Перед dispatch повторно проверяются зависимости, включая вновь появившийся динамический маршрут, который намеренно не входит в конфигурационный fingerprint. Native игнорирование команды даёт partial; потеря ответа — unknown без повтора. SQL/реальный HTTP и модели проверили оба entry point, поздние DB/Firewall связи, границу адресов, переименование/очистку comment, opaque drift, counters, порядок, отсутствие скрытого каскада и no replay. MCP SQL с настоящим pg_dump/pg_restore, backend TypeScript, ESLint и профиль live-maps прошли; расширенная CHR-приёмка подтверждает rename/comment-clear/delete на обоих устройствах. + +Запуск: `node backend/scripts/test-mcp-postgres.mjs --chr-gre-mutation` с `MMAPP_TEST_CHR_HOSTS`, `MMAPP_TEST_CHR_TUNNEL_PORTS`, `MMAPP_TEST_CHR_USER`; пароль вводится скрытым запросом. Тестовая PostgreSQL изолирована от приложения. + +В коде 305 инструментов; реестр содержит 147 mediated, 62 excluded, 53 pending из 262 REST-маршрутов. Дополнительные BGP export-план и observer префиксов не закрывают отдельные legacy REST endpoints. Модели/coverage, финальные SQL/restore и профиль traffic-rate серверной/пользовательской истории трафика прошли. Подробности ниже. Остаток плана и отдельная сетевая приёмка сохраняются. + +`mm_recursive_routes_get` и `mm_recursive_gateways_get` используют общие readers legacy REST. Маршруты возвращают явные live/stale и ограниченный кеш выбранного сервера при неподтверждённом источнике; шлюзы сохраняют up/down/unknown. Пустой успешный ответ отличается от ошибки. MCP-чтение не обновляет кеш и историю, `forwardingVerified` остаётся false. Строгие `*_catalog_get` сохраняют отказ при ошибке источника. + +Обновление REST-кеша рекурсивных маршрутов сохраняет логические DB ID, сортировку и country, включая native recreation. Неизменённые строки сохраняют updated_at. Явные DB ID допустимы только для выбранного сервера; copy не переносит чужие ID. Без ID используются точные совпадения, затем единственная пара префикс/таблица; неоднозначное сопоставление отклоняется. Маршрут с BGP-ссылкой rec: нельзя удалить или переназначить на другой префикс/таблицу. Изменение gateway/distance сохраняет ссылку. Для BGP и recursive writes действуют общие блокировки; отсутствующая/некорректная rec-ссылка не подменяется ручным gateway. + +Приёмка стабильных ID и новых readers завершена на обоих CHR 19 октября 2026 в 11:56:57 UTC: отключённые маршруты в собственной non-FIB таблице, native recreation, повторные чтения с rec-binding и отсутствие observer metadata writes. Проверены исходные fingerprints и очистка; SSH закрыт. Отчёт: `docs/mcp-chr-recursive-restore-acceptance.json`. Передача пакетов этим тестом не подтверждается. Также прошли backend build, recursive/MCP модели, ESLint и PostgreSQL-интеграция с настоящим pg_dump/pg_restore. + +`mm_recursive_routes_replace_plan` подключает полный apply/copy набор REST `/recursive-routes`: до 50 желаемых и 50 текущих управляемых маршрутов, обязательный список `routes`, включая явный `[]`. Требуются scope сервера, право network:write, человеческое подтверждение и бюджет всех create+delete+metadata операций. Копирование не переносит DB ID с другого сервера. План фиксирует native-конфигурацию, таблицы/интерфейсы, кеш и BGP-ссылки; их дрейф блокирует выполнение. Ссылки rec: сохраняются при допустимом редактировании; удаление или смена префикса используемого маршрута отклоняются до записи. + +Общий исполнитель recursive apply/restore после каждого PUT подтверждает новый маршрут, перед DELETE проверяет исходный объект и после него отсутствие ID. Проигнорированный PUT, повторно использованный ID или изменённый target останавливают пакет до удаления следующего объекта. Неизвестный ответ даёт unknown, подтверждённый частичный результат — partial; автоматически повторять пакет или выполнять универсальный inverse нельзя. Итоговая проверка native-набора, кеша и ревизии выполняется отдельно. Forwarding не заявляется. + +Расширенная приёмка этого плана завершена на обоих CHR 19 октября 2026 в 12:07:12 UTC: редактирование отключённого non-FIB маршрута при существующей BGP-ссылке, сохранение логического ID, per-command read-back, пустой apply/copy-набор, затем восстановление пустой ревизии и исходных fingerprints. Отчёт обновлён тем же тестовым runner; стенд очищен, туннель закрыт. Повторены recursive/MCP модели, live-maps, build, ESLint, SQL-интеграция и настоящий pg_dump/pg_restore. + +`mm_backup_job_get` читает реальную очередь REST `/backups/jobs/:jobId`, в том числе через stdio-прокси. Требуются backups:read и доступ ко всем серверам задачи; смешанная задача не возвращает даже общий прогресс. Проверяются актуальный каталог серверов, числа результатов и терминальный статус. Ответ содержит status/completed/total, признаки failures/upload errors и отдельные ограниченные страницы created/failures. Заметки, имена файлов/устройств, ключи S3 и тексты ошибок исключены; ошибка показывается фиксированным сообщением. Известный нулевой размер остаётся нулём, отсутствие сведений о локальном файле — unknown. + +Источник этого статуса — process-memory (`ephemeral:true`): закрытие приложения и восстановление БД очищают задачи. Отсутствующая или неоднозначная задача не угадывается по событиям/файлам. Это наблюдение за REST-очередью, а состояние самого MCP-выполнения остаётся в `mm_operation_get`. Проверены настоящая REST-очередь с контролируемым исполнителем, pending/done/partial upload, scopes и отсутствие утечек в ответе/аудите, очистка при app close/restore, build, backup-профиль, SQL/MCP и настоящий pg_dump/pg_restore. + +После блока recursive routes и backup jobs выполнена полная backend-регрессия `test:mcp:pg:all --restore`: все профили `test:all`, заключительный SQL logs и настоящий pg_dump/pg_restore. Итог `Regression failed profiles: none`, exit 0. Результат относится к этому состоянию; дальнейшие блоки проверяются отдельно. + +`mm_internet_path_latest_get` подключает сохранённый Internet path из общего REST latest-reader. `serverOffset/serverLimit` выбирают видимый подграф, а `offset/limit` ограничивают GRE, probes, rulesets и вложенные rules/uplinks. Speed probes включаются только при двух концах внутри выбранного подграфа. JSON отсекается по server IDs в SQL до разбора, включая map keys; скрытые повреждённые записи с известным владельцем не попадают в модель. Непривязанные записи, повреждённые обязательные коллекции и дубли отклоняются. Старый JSON-string payload scoped reader не декодирует целиком; его REST-совместимость сохранена. + +Ответ возвращает только разрешённые поля; GRE PSK, пароли серверов, комментарии/описания и тексты probe errors не выдаются. Ноль сохраняется, отсутствующие метрики остаются null. `source:stored-application-interpretation`, `selectedSubgraph:true`, `live:false`, `currentRoutingVerified:false` и `forwardingVerified:false` отличают старую интерпретацию коллектора от текущих сетевых измерений. `sourceCollectionComplete` описывает исходный сбор; `incomplete` учитывает страницы/отсутствующие серверы. `maxAgeSec` задаёт порог stale (по умолчанию 900 секунд); дата из будущего помечается clockSkew и stale. Этот снимок не заменяет ECMP preflight или проверку пути. + +Для Internet path отдельно прошли модели, MCP/SQL-интеграция с реальным REST timestamp/parity, проверка scope до разбора hidden/mixed данных, отсутствие секретов в результате/аудите и запросов к устройствам, backend build и ESLint. Полная backend-регрессия выше была выполнена перед этим следующим блоком. + +`mm_backup_storage_update_plan` подключает PUT backup storage через тот же `updateBackupStorageSettings`, что REST. Нужны backups:write, allowGlobal, все текущие серверы в scope и человеческое подтверждение. Полный каталог, storage-защита и защиты серверных зависимостей проверяются до записи. Дрейф конфигурации, ключа, выданной ссылки, серверов/remote-archive state или занятость созданием/синхронизацией блокируют выполнение. Timestamps и результаты наблюдающего S3-теста не превращаются в конфигурационный дрейф. + +Секретное значение в settings запрещено. Новое значение передаётся только как `secretRef`, выданный владельцу подключения без serverId с purpose `backup-s3:||`, например `backup-s3:https://s3.example.test|us-east-1|backups`. Endpoint/region/bucket берутся из планируемой конфигурации с удалением внешних пробелов; URL с credentials/query/hash и разделитель `|` в region/bucket отклоняются. Без ссылки ключ сохраняется. Перенос сохранённого ключа на другое usable-назначение требует ссылки для нового назначения. Пустой bucket разрешает очистить неиспользуемые настройки без обращения к S3, но последующее восстановление usable-назначения снова требует подходящей ссылки. При существующих S3-архивах сохраняются прежние запреты смены provider/endpoint/region/bucket/path-style. + +Приватный отпечаток секрета учитывает постоянный серверный ключ вне восстанавливаемой БД и назначение; сам отпечаток не выводится в план/аудит. Plaintext секрета не хранится в шагах плана. Ссылка повторно проверяется перед записью. После неё сравниваются вся конфигурация и ключ, включая непрошенные изменения полей; несовпадение даёт partial без автоматического повтора. `storageTestVerified:false` прямо отделяет сохранение от проверки доступности S3; для неё используется отдельный `mm_backup_storage_test_plan`. Смена подключения/ключа сбрасывает прежний результат теста по правилам общего REST-сервиса. + +Для storage-плана прошли модели назначения/ключевого fingerprint, backups HTTP-профиль, build/ESLint и SQL/MCP: scope/approval, первичная настройка, ротация/перенос, invalid/deleted/foreign references, busy/catalog/key drift, защита архивов, игнорирование/непрошенное изменение поля и idempotency. Настоящий pg_dump/pg_restore также повторён после keyed-proof; последняя ветка очистки bucket проверена отдельным финальным SQL-прогоном. Внешние запросы S3 этим тестом не выполнялись. Полная backend-регрессия выше относится к более раннему recursive/backup-jobs состоянию. + +`mm_ospf_application_preview` подключает POST `/servers/:id/ospf/optimize/preview` через общий reader рейтинга приложения. Scope ограничивает SQL probes и опрос OSPF Router IDs до чтения; `peerServerIds` позволяет выбрать подмножество разрешённых устройств (не более 10 вместе с источником). `offset/limit` ограничивают interfaces/changes. Источник и актуальные права проверяются перед каждым GET/POST и после анализа, сетевой timeout ограничивается остатком 45-секундного deadline. Отмена/ошибка закрывает запросы группы; отмена GET и ошибка конструктора соединения проверены отдельно. + +Значения legacy-модели с её округлениями, минимумами и fallback вынесены в `modeled`, исходные метрики — в `observed`. `proposalOnly:true`, `metricContextVerified:false`, `executionAvailable:false` и `forwardingVerified:false` не позволяют выдавать этот рейтинг за проверенный путь или готовый план. История интерфейса использует только latest aggregate row с известными counters/continuity; первый/ошибочный sample и отдельный WG peer не становятся измеренной скоростью. При неизвестных данных observed остаётся null, даже когда модель использует fallback. Измеряемый `mm_ospf_optimizer_plan` с family/VRF и свежими наблюдениями работает отдельно. Legacy POST `/ospf/optimize` покрывает `mm_ospf_application_optimize_plan`: он заново готовит полный human-only план и не применяет read-only preview напрямую. + +SQL-приёмка OSPF preview проверила REST parity, hidden/disabled probes, peer scope, известный ноль/unknown, continuity flags, страницы, пустой/повреждённый каталог, отключённый источник, отмену и остановку следующего peer после паузы. Прошли OSPF/transport модели, build и ESLint. Read-only приёмка на обоих CHR завершена 19 октября 2026 в 13:37:07 UTC: 22 сценария, 15 неизменных fingerprints, пустые OSPF templates. Отчёт `docs/mcp-chr-catalog-acceptance.json`; мутаций/планов записи нет, SSH закрыт. Непустой рейтинг принят SQL-стендом, forwarding и cost switching этим запуском не подтверждаются. + +Финальные `test:mcp` (после обновления coverage) и `test:live-maps` выполнены, оба процесса завершились с кодом 0. Coverage подтвердил 292 инструмента, 137 mediated / 62 excluded / 63 pending. Предыдущая недоступность review из-за лимита аккаунта устранена: после проверки доступности аккаунта повторные запуски прошли штатную проверку разрешений. Обход проверки не выполнялся. + +Повторная полная backend-регрессия после Internet path/storage/OSPF выявила зависимость границ разделов PostgreSQL от часового пояса сессии. `ensurePartitionFor` теперь передаёт явные UTC instants вместо date-only literals. Новый SQL-сценарий проверяет сутки, неделю, високосный месяц, последний millisecond/следующую границу и DATE-keyed aggregates при UTC+7, UTC и DST-зоне. После исправления `test:mcp:pg:all --restore` прошёл целиком: `Regression failed profiles: none`, exit 0, включая настоящий pg_dump/pg_restore и заключительный SQL logs. Build и ESLint прошли. + +На разрешённом Debian-стенде отдельно выполнен read-only аудит PostgreSQL 18.6: timezone UTC, все 85 timestamp-разделов десяти родителей имеют обе границы в полночь UTC. [Отчёт](mcp-postgres-partition-audit.json). Транзакция завершена ROLLBACK, SSH закрыт; deployment и изменение данных не выполнялись. Правка не перестраивает уже существующие разделы: установки с прежними границами не в UTC требуют отдельного плана переноса данных перед включением новой схемы создания разделов. DATE-keyed monthly parents не входят в этот timestamp-аудит. + +Заключительный `npm run test:mcp` из корня прошёл: 9 браузерных сценариев, exit 0. Проверены выдача доступа/токена, видимые ошибки и узкий экран, SLA-план Route AI, скрытие management-пароля, отдельный recovery-план, URL-фильтры обращений, планирование изменения интерфейса, человеческая загрузка архива БД и просмотр исходного состояния/зависимостей/шагов до подтверждения. Playwright использует mocked application API: это проверка web-сценариев, а не настоящего портала. [Сводный отчёт регрессии](mcp-regression-acceptance.json) сохраняет команды, границы проверки и открытый остаток плана. + +`mm_traffic_servers_history_get`, `mm_traffic_server_history_get` и `mm_traffic_server_interfaces_get` используют общие с REST readers сохранённых samples. Нужен `mm:traffic:read`; список выбирает только enabled-серверы, отдельная история допускает выключенный источник и прямо показывает disabled. `range` — 5m/15m/1h/4h/24h, по умолчанию 1h; отдельный сервер допускает `interface`. Список ограничен `serverOffset/serverLimit` до 10 устройств; каталог интерфейсов за 24 часа — `offset/limit` до 200 записей. Будущие samples, loopback и отдельные WG peer rows не становятся интерфейсным агрегатом. + +Scope и временный grant ограничивают запросы samples, collection state и metadata до чтения. Набор больше 20000 rows на серверную историю или 100000 rows на интерфейсный каталог отклоняется целиком; частичная сумма не возвращается. После SQL снова проверяются актуальные права подключения/владельца и неизменность источника. Отмена, pause, owner permission removal и изменение host во время SELECT не выдают старый результат. Чтение не опрашивает RouterOS и не создаёт конфигурационных планов. + +Скорости/peaks и каждое значение series возвращаются как null без соответствующей observation mask; подтверждённый ноль сохраняется. Raw error strings, server host/password/name/site, peer keys и скрытые/будущие interface names не отражаются. `currentState`, sampled/attempt/success/expiry timestamps отделяют сохранённые измерения от текущего состояния. Running/disabled интерфейса nullable при неизвестном statusKnown и описывают последний retained sample; current interface state не подтверждается. `accountedRxGiB/accountedTxGiB` — сумма пригодных сохранённых дельт, а не доказанный полный сетевой объём. Направление router-interface-rx-tx; дедупликация интерфейсов, полное покрытие истории, актуальность snapshot-status и forwarding не заявляются. + +SQL/MCP проверил REST parity, известный ноль/unknown, aggregate/WG peer, hidden/disabled scope, grant narrowing, error/pending/stale, paging, future exclusion, 20001 скрытых строк и переполнение выбранной истории, source/permission/pause races, отмену и безопасный persisted request result. Общий traffic-rate профиль и SQL logs прошли в отдельной БД; после последних guards повторены MCP SQL и настоящий pg_dump/restore. Build, ESLint и test:mcp (295 tools, 140/62/60 routes на этом этапе) прошли. OSPF fixture приведён к недельному partition kind для traffic_samples. Полная регрессия и web-отчёт выше предшествуют серверной истории; пользовательские адаптеры описаны ниже. Forwarding этим чтением не принимается. + +`mm_traffic_users_history_get`, `mm_traffic_user_history_get` и `mm_traffic_bound_interfaces_get` используют общий REST builder с заранее выбранным SQL-каталогом пользователей/привязок. Permission — mm:traffic:read. Eligibility, исключение mixed users с любой привязкой вне grant, user paging и выбранные bindings вычисляются одним SQL statement до JSON/hydration. Private permissions, комментарии и peer names не гидратируются; peer public key нужен только внутреннему расчёту конкретного WG peer и не выдаётся, в том числе внутри составного ID. + +Окна range — 5m/15m/1h/4h/24h. Параметры serverId/serverOffset/serverLimit выбирают не более 10 серверов, включая отключённые для исторического чтения. У списка пользователей offset/limit (до 50) и bindingOffset/bindingLimit (до 200); у detail — userId и binding pages. У плоского каталога привязок отдельные userOffset/userLimit и offset/limit для bindings внутри выбранной страницы пользователей. totalBindingsInUserPage не объявляется глобальным числом привязок. Более 200 выбранных привязок одного пользователя или 20000 samples сервера отклоняют расчёт целиком. + +metricsScope:selected-bindings, bindingCount/selectedBindingCount и completeSourceSet отделяют subtotal от полного набора разрешённых источников. Связи с историческим владельцем и дедупликация перекрывающихся интерфейсов не подтверждаются. accountEnabled не подменяет сетевое состояние, enabled WG peer не объявляется online без handshake. Скорости/peaks/series используют общую observation mask: известный ноль сохраняется, неизвестное остаётся null. includeUnbound для пользователей без привязок требует allowGlobal и включения всех текущих серверов в scope; по умолчанию false. Временный grant сужает eligibility, а изменение привязок/источника или прав после SQL блокирует ответ. + +При проверке будущих samples обнаружено, что прежний REST reader мог вычислять неверный legacy rxNow из интервала вне окна. Общие readers серверов, интерфейсов и пользователей теперь по умолчанию ограничивают SQL сверху rangeEnd; финальный SQL проверил правильную скорость в REST и MCP после добавления sample из будущего. Это исправление не меняет расчёт корректных данных внутри окна. Расчёт кеширует историю только на один запрос, по одному чтению на сервер; пустой injected catalog не возвращается к глобальному listUsers. + +Проверены REST parity, разрешённый WG peer без ключа/комментариев, unknown first sample, disabled server/account, nested pages, полный/неполный source set, hidden mixed user, narrowing grant, pre-abort, 201 bindings, глобальный unbound guard и перенос привязки в скрытую область во время SELECT. Observer-планов и native запросов нет; persisted results не содержат контрольных секретов. Финальные MCP SQL/pg_dump/restore, build, ESLint и модели/coverage (298 tools, 143/62/57) прошли. После восстановления лимита штатный review разрешил дополнительный запуск с последним window fix: traffic-rate и SQL logs прошли, Regression failed profiles: none, exit 0. Прежний отказ review не обходился. В owned traffic-status SQL teardown теперь ожидает завершение scheduler jobs/journal до закрытия pool; заключительный профиль не содержит прежнего сообщения Cannot use a pool after calling end. [Отчёт](mcp-user-traffic-history-acceptance.json). + +`mm_bgp_export_plan` изменяет только `output.redistribute` одной статической BGP connection: `connected`, `static` или `disabled`. Одновременно нужны `mm:bgp:write`, `mm:network:read` и `mm:filters:read`; человеческое подтверждение обязательно даже при autonomous/explicit-allow. Широкие или смешанные существующие режимы отклоняются. Instance, connection, VRF и таблица должны иметь явный согласованный контекст. Переключение не включает отключённого peer. `disabled` отключает перераспределение, а существующие `output.network`, `default-originate` и filters остаются действующими. + +План сохраняет источники connected/static обоих семейств, адреса, фильтры и граф используемых шаблонов в приватных HMAC proofs. Смена credentials/назначения после approval блокируется до обращения к новому адресу. Пустой успешный `/get` означает unset; значения разрешаются через шаблоны, циклы и неоднозначные родители отклоняются. Очистка, которая вернула бы экспорт из активного шаблона, запрещена. Если RouterOS не возвращает флаг dynamic, требуется положительный результат native static query с точным ID; отсутствие флага само по себе недостаточно. До 500 исходных маршрутов, 100 шаблонов, глубина 16 и до 1000 объектов зависимостей; действует также меньший лимит подключения. Public preview содержит происхождение маршрутов и nullable active, не утверждает, что они анонсированы. Секреты и исходный текст фильтров в proof не раскрываются. + +Перед записью повторно проверяются источники, контекст, права и защиты, после неё — native read-back всей конфигурации. No-op не отправляет PATCH; ignored/unexpected write становится partial, потеря ответа — unknown, автоматического повтора нет. SQL-приёмка с настоящим pg_dump/restore проверила эти исходы, drift credentials/маршрутов/фильтров/шаблонов, conjunctive permissions, peer disabled, nil/static-query guards и отсутствие контрольных секретов в сохранённых ответах. Полная backend-регрессия после traffic/BGP прошла: 26 профилей test:all, дополнительный SQL logs, MCP SQL и настоящий pg_dump/restore; Regression failed profiles: none, exit 0. Последний static-query fallback отдельно прошёл build, ESLint и SQL/restore. BGP prefix/Community и forwarding принимаются отдельно по фактическим CHR-наблюдениям. + +В реальной подготовке BGP-стенда исправлен read-back VRF с несколькими интерфейсами: RouterOS меняет порядок членов и может вернуть массив вместо CSV. Состав одной VRF теперь сравнивается как нормализованный набор; порядок самих VRF остаётся значимым, поскольку определяет приоритет. Пустой набор/none, дубли, неоднозначные значения и array/string parity проверены моделями и SQL. [Первый native-прогон](mcp-chr-bgp-prefix-initial-failure.json) сохраняет исходный отказ и подтверждённую очистку. + +Для RouterOS 7.24.5 unset D flag выбирается native query `-dynamic` с точным ID и AND; `dynamic=false` не совпадает с unset. Это следует [семантике API query](https://manual.mikrotik.com/docs/developer-guides/api/), а не предположению по одному отсутствующему JSON-полю. [SQL-отчёт BGP export](mcp-bgp-export-acceptance.json) отделяет проверку конфигурационного исполнителя от сетевого результата. [Проба BGP по IPv4 transport](mcp-chr-bgp-prefix-ipv4-transport.json) действительно получила IPv6-префикс, но с неактивным IPv4-mapped next-hop: negotiated AFI и наличие префикса не считаются активным маршрутом. Следующий стендовый сценарий использует отдельные connections по IPv4 и IPv6. + +`mm_bgp_prefixes_get` — observer с совместными `mm:bgp:read` и `mm:network:read`. Вход: один `serverId`, `family: ipv4|ipv6`, явная `table` (default main), `offset/limit` до 200. Читает фиксированный native route view с фильтром таблицы, подтверждает её идентичность до/после и ограничивает весь ответ 10000 строками до пагинации. Достоверные BGP/dynamic origins и префиксы обязательны; неизвестное происхождение, смешанные flags, дубли IDs и переполнение отклоняют ответ целиком. Поля ответа ограничены ID/семейством/таблицей/префиксом, nullable active и distance; ключи, комментарии и сырые native поля не выдаются. + +Изменение источника, pause, сужение политики или отзыв права владельца во время чтения блокирует результат. Отмена не отправляет следующий GET; in-flight GET использует abortable transport. Сохраняются время получения, fingerprint и страницы, но `atomicSnapshot`, `sessionAttributionVerified`, `advertisedRoutesVerified` и `forwardingVerified` остаются false. Это view установленной RIB, не полные BGP attributes и не привязка к конкретному peer. Проверены модели, SQL/настоящий pg_dump/restore, build и ESLint; SQL отдельно подтверждает, что owner/pause race достигли native чтения, без observer-планов и отражения секретов. Покрытие — 300 tools, 143/62/57 REST. + +`MMAPP_TEST_CHR_BGP_EXPORT=true` вместе с BGP/overlay флагами включает [двухузловую prefix-приёмку](mcp-chr-bgp-prefix-acceptance.json). На RouterOS 7.24.5 она завершилась exit 0 20 октября 08:18:38 UTC: четыре сессии (отдельный IPv4/IPv6 transport на каждом CHR), восемь человеческих export-планов, активные /32 и /128 в обе стороны через `mm_bgp_prefixes_get`, явный отзыв и повторный анонс. Остановка IPv4 peer сохранила IPv6; обратный ROS-план вернул IPv4. Общий GRE fault восстановлен отдельным человеческим service-планом: универсальный ROS-only inverse к GRE service-step неприменим. BGP baseline и все 20 fingerprints десяти ресурсов двух CHR восстановлены. Тест не менял настройки underlay/firewall и не очищал исторический session cache. Принят connected export; static export проверен SQL, реальная static redistribution этим прогоном не подтверждается. Community и независимые физические пути остаются отдельной приёмкой; передача контрольных пакетов проверена следующим блоком. + +Дополнительный `MMAPP_TEST_CHR_BGP_FORWARD=true` сохраняет отдельный [отчёт пакетной приёмки](mcp-chr-bgp-forward-acceptance.json); он завершился exit 0 20 октября 08:28:01 UTC. MCP `mm_route_path_verify` проверил 18 состояний: 11 успешных IPv4/IPv6 ping 3/3 с подтверждённым traceroute до BGP loopback, 7 подтверждённых отказов 3/0 после withdrawal/peer stop/удаления connections. Повторный анонс и inverse возвращают передачу; IPv6 продолжает работать при остановке IPv4 peer. Прямой маршрут к test loopback берётся из BGP, обратный — из связанной GRE-подсети. Это двухузловые контрольные пробы, без утверждения о пользовательском трафике, независимых физических путях или полном транзитном failover. Все 20 fingerprints восстановлены, SSH-туннель закрыт. Предыдущие prefix/SQL-отчёты сохранены отдельно; false forwardingVerified в observer DTO остаётся корректным: сам route view передачу пакетов не проверяет. + +### Полная замена Community-фильтров и восстановление ревизии + +`mm_filter_rules_replace_plan` / `filters.rules.replace` и `mm_filter_revision_restore_plan` / `filters.revision.restore` закрывают PUT `/api/filters/rules` и POST `/api/filters/revisions/:id/restore` через общий REST/MCP сервис. Нужны одновременно `mm:filters:write`, `mm:network:read`, `mm:bgp:read`; даже autonomous grant требует человека. Запечатываются исходный сервер, revision/checksum, metadata/native rule и зависимости GRE/recursive, BGP/templates, OSPF/RIP/IS-IS/select, адреса, статические маршруты, таблицы/VRF. Серверные защиты применяются ко всем стадиям, включая изменение только метаданных. `requireUnbound` дополнительно запрещает существующие routing contexts и чужие filters. + +До 1000 стандартных Community, dependency rows и объектов защиты; тело до 64 KiB. IPv4/IPv6 gateway задаётся literal IP, recursive reference должен совпадать с сохранённым и native маршрутом. Один собственный `bgp-in` набор создаётся/заменяется/удаляется; чужие правила и порядок сохраняются. Неизменное native тело не отправляется повторно. После native read-back сохраняются cache/revision, затем проверяются все три состояния. Неподтверждённая запись не повторяется автоматически. + +Компилятор использует документированные `set blackhole yes` и `set gw-interface` по имени интерфейса ([RouterOS Route Selection and Filters](https://help.mikrotik.com/docs/spaces/ROS/pages/74678285/Route+Selection+and+Filters)). Native routing-filter не принимает script-комментарии `#`; descriptions/communityName сохраняются в scoped metadata и ревизиях, reader указывает `annotationsSource`. Имена GRE ограничены проверенными ASCII буквами/цифрами и `_ . : @ / -` с пробелами. Кавычки, `$`, обратная косая черта и разделители отклоняются до опроса/записи: RouterOS CLI escaping и native menu ID не являются надёжным представлением имени в этом языке. Интерфейс автоматически не переименовывается. + +[Native приёмка](mcp-chr-filter-write-acceptance.json) завершена на двух CHR RouterOS 7.24.5 20 октября в 14:30:15 UTC: создание, замена, повтор без PATCH, копирование метаданных, восстановление первой ревизии, literal gateway и GRE с пробелом в имени, многострочное описание. На каждом CHR восстановлены 11 исходных fingerprints. `communityPropagationVerified` и `forwardingVerified` остаются false: routing contexts отсутствовали, GRE был отключён. Диагностические неуспешные отчёты сохранены; [отдельная recovery-приёмка](mcp-chr-filter-write-recovery.json) подтверждает удаление оставшегося тестового GRE и восстановление baseline. Текущий реестр: 302 tools, REST 145/62/55 из 262; полный план остаётся открыт. + +### Сводка uptime в MCP + +`mm_uptime_overview_get` / `uptime.overview.read` закрывает GET `/api/uptime/overview` общим `readUptimeOverview`. SQL ограничивает probes, samples и scheduler provenance выбранными серверами до чтения в Node. Серверные и probe-страницы независимы: до 10 серверов, 200 проб на страницу, 1000 проб каталога, 20000 samples проб суммарно на страницу и 20000 ресурсных samples на сервер. Диапазон 5m–24h ограничен с обеих сторон; будущие samples исключаются также в REST. + +MCP не использует нулевые defaults и искусственно дополненную историю UI. Неизвестные RTT/loss/CPU/memory/uptime остаются null; настоящий ноль сохраняется. Последняя попытка отделена от исторического успешного fallback, указаны timestamps/возраст и состояние server.enabled. Просроченный fallback не возвращается. `measurementKnown` отражает сохранённый флаг и доступные overrides scheduler failures; `nativeMeasurementVerified`, `currentStateVerified`, `atomicSnapshot` и `forwardingVerified` остаются false. Это сохранённая история, не новый запуск проб и не доказательство текущего пути. + +Исходная конфигурация probes и серверов, права владельца/соединения и pause повторно проверяются перед выдачей. SQL-приёмка проверила 20001 скрытый sample, реальные публичные пороги, ноль/unknown, страницы, старый/просроченный fallback, REST parity и будущие samples. Гонки source/probe/owner/pause и отмена происходят во время реально заблокированного SQL-чтения, что подтверждается `pg_stat_activity`. Ошибки provider и пароли не отражаются, observer не создаёт планов и не обращается к устройствам. MCP models/coverage, backend build, ESLint, SQL, профиль probes, SQL logs и настоящий pg_dump/pg_restore прошли. Покрытие: 303 tools, REST 146 mediated / 62 excluded / 54 pending из 262. [Отчёт](mcp-uptime-overview-acceptance.json). + +### Сопоставление GRE hostname через менеджер + +`mm_network_hosts_resolve` / `network.hosts.resolve` закрывает POST `/api/network/resolve-hosts` общим `resolveNetworkHosts`. Требуются явный global grant и `mm:network:write`, как у существующего REST endpoint. Конфигурация не меняется, поэтому инструмент доступен observer; разрешение других write-операций этим не предоставляется. До 32 входов: строгие hostname/IP, IDN через IDNA, case/IDNA dedup, literal IPv4 без запроса. Literal IPv6 возвращает null с `unsupported-family`, не попадает в DNS и не мешает остальному пакету. + +Resolver работает на хосте менеджера. DTO явно не подтверждает resolver RouterOS, принадлежность адреса интерфейсу и forwarding. Кэш до 512 записей на пять минут является политикой менеджера, не DNS TTL. Один lookup ограничен ожиданием 2 s, пакет — 10 s; не более восьми реальных незавершённых OS lookup на процесс. Таймаут/отмена вызывающего не освобождает pending capacity до завершения настоящего lookup; повторный hostname использует тот же pending lookup. Timeout/busy/unresolved различаются, upstream error payload не выдаётся. Права/pause/политика и отмена проверяются между hostname и после ожидания. REST disconnect останавливает дальнейшие lookup без преждевременной отмены после получения POST body. + +Модели с mock resolver и реальным локальным HTTP проверяют TTL/eviction, IDNA/dedup, retained pending capacity, общий deadline, revoke/cancel, отключение клиента и REST parity. SQL проверяет global/permission boundary, observer, строгие входы, owner revoke/pause и отсутствие планов; внешние DNS и RouterOS для этой приёмки не вызываются. MCP/coverage, build и ESLint прошли. Текущий реестр: 304 tools, REST 147 mediated / 62 excluded / 53 pending из 262. [Отчёт](mcp-network-host-acceptance.json). + +### Общий контроль после Community, uptime и DNS + +Полный `test:mcp:pg:all -- --restore` завершился с кодом 0: все 26 backend-профилей присутствуют в логе, `Regression failed profiles: none`, MCP/SQL, настоящий pg_dump/pg_restore и SQL logs прошли. Финальные build, ESLint, MCP models/coverage и diff-check прошли. [Отчёт](mcp-routing-monitoring-regression-acceptance.json). Текущий checkpoint — 304 инструмента и REST 147/62/53. UI не менялся в этой итерации; прежние браузерные сценарии остаются отдельной исторической приёмкой. Полная Community propagation, независимые пути и трёхузловая аварийная приёмка, остальные сервисные адаптеры и external portal/client acceptance по-прежнему открыты. + +### Локальная привязка input/output цепочки BGP + +`mm_bgp_filter_binding_plan` / `routing.bgp.filter.bind` меняет ровно `input.filter` либо `output.filter-chain` одного подтверждённого static peer. Имена native свойств соответствуют [RouterOS BGP](https://help.mikrotik.com/docs/spaces/ROS/pages/331612228/routing+bgp). Требуются одновременно BGP-write, filters-read, network-read и человек, включая autonomous explicit-allow. Выбирается существующая цепочка; её код не меняется. Пустая chain очищает локальную привязку только при подтверждённом пустом наследовании. Никакого неявного refresh, изменения templates или redistribution нет. + +Private HMAC запечатывает credentials/назначение, полную конфигурацию connection, все filter rules с порядком, используемые templates и граф наследования, исходный instance/VRF/table и защиты. Контекст подтверждается по name/ID и native `/get`, без угадывания main; local nil отличается от explicit empty. Один local override может перекрывать разные parent значения, но неоднозначное наследование без override и очистка в активный inherited filter отклоняются. До 100 templates/16 уровней, 1000 connections/filter rows/parent rows и объединённых объектов с дальнейшим policy cap. Права и защиты проверяются перед записью, включая no-op; источник с изменившимися credentials не опрашивается. Read-back сравнивает целевой binding и оставшуюся конфигурацию/зависимости; ignored/partial/unknown не повторяются. Общий static-peer reader используется также BGP export и положительно подтверждает отсутствие D-флага. + +[Native приёмка](mcp-chr-bgp-filter-binding-acceptance.json) завершена на двух CHR 20 октября 18:24:44 UTC, exit 0. В собственном GRE/VRF проверены input на первом узле и output на втором: цепочка с заведомо несовпадающей Community отклоняла IPv4-путь; после снятия binding путь восстанавливался, IPv6 сохранялся. Каждый refresh выполнялся отдельными подтверждёнными disable/enable планами своего peer. Всего 26 BGP control-probe проверок: 17 с 3/3 и 9 подтверждённых отказов с 3/0, включая прежние withdrawal/session сценарии. Все 22 overlay fingerprints (11 на CHR) восстановлены; SSH закрыт. + +Это проверка привязки и реального действия фильтра с implicit reject, а не приёмка отправки Community attributes. Native non-empty template inheritance, Community tagging/propagation, независимые физические пути и трёхузловая приёмка остаются открыты. SQL/HTTP отдельно проверили наследование/default/ID контексты, template cycle, strict args/права, human-only, credential/no-poll и реальный late fence после application intent, защиты для no-op, ignored/partial/unknown и idempotency без повторной записи. Все 26 backend-профилей, MCP/SQL, настоящий restore, SQL logs, модели/coverage, build и lint прошли. Реестр: 305 tools, REST 147/62/53; новый binding не выдаётся за отдельный legacy REST adapter. [Сводный отчёт](mcp-bgp-filter-binding-acceptance.json). + +## Export policy стандартных BGP Community + +`mm_bgp_community_policy_plan` (`routing.bgp.community.policy`) создаёт, заменяет или удаляет одну управляемую цепочку `mmapp-bgp-out-`. Требуются одновременно `mm:filters:write`, `mm:bgp:write`, `mm:network:read`, область выбранного сервера и подтверждение человека, включая автономный режим. Новый инструмент нужно явно добавить в разрешённые операции подключения. + +Пример аргументов для плана, который помечает один IPv4-префикс: + +```json +{ + "serverId": 2, + "policyId": "selected-export", + "rules": [ + { + "prefix": "192.0.2.1/32", + "match": "exact", + "communityMode": "set", + "communities": ["64512:999"], + "action": "accept" + } + ], + "defaultAction": "reject" +} +``` + +Вход — типизированные IPv4/IPv6 CIDR; адрес приводится к адресу сети. Для `match: subnet` можно ограничить `minLength`/`maxLength`. `communityMode` принимает `keep`, `set`, `append`, `clear`; Community — только пары uint16. `action` принимает `accept`, `reject`, `continue`. Continue разрешён только при изменении Community; reject не меняет атрибуты. Порядок значим, максимум 500 правил и 64 KiB скомпилированного тела. Пользовательский код RouterOS не принимается. + +В плане `previousPolicy` показывает текущие типизированные правила, если native тело в точности воспроизводится этим компилятором. `desiredPolicy` показывает желаемый результат. Для неизвестного native кода `previousPolicy: null`, `previousPolicyRecognized: false` и отдельное предупреждение: смысл произвольного скрипта не угадывается, исходный текст не передаётся в preview. Это не снимает обязательного подтверждения человеком. + +Цепочка начинает влиять на обмен после отдельного `mm_bgp_filter_binding_plan` с native ID существующего статического peer, `direction: output`, `chain: mmapp-bgp-out-selected-export`. Default reject отклоняет все остальные префиксы. Если цепочка уже привязана, её замена влияет на всех использующих её клиентов. Обновление существующей policy включает её; это отражено в плане. Перезапуск peer не выполняется автоматически. + +Удаление: тот же policyId, `action: delete`, `rules: []`, `defaultAction: reject`. Сначала необходимо отдельными планами убрать привязки; проверяются native BGP connections/templates, другие routing clients и jump references. Чужой маркер, совпадение имени чужой цепочки, неоднозначные ссылки и непрочитанные зависимости блокируют запись. Пустой replace создаёт reject-all цепочку и отличается от delete. + +До dispatch повторно проверяются credentials, конфигурация, зависимости, права, защиты и application intent; после записи проверяются native ID, включённость, тело, положение правила и неизменность остальных цепочек. Потерянный ответ не вызывает автоматическую повторную запись. Чтение RouterOS может убрать кавычки вокруг простого имени `gw-interface`: сравнение учитывает только эту наблюдённую форму, сохраняя строгое сравнение команд, значений, порядка и прочего текста. При добавлении/удалении policy RouterOS обновляет служебный `.nextid` у соседнего правила. Сравнение остальных правил после записи проверяет их фактическую последовательность и содержимое, исключая этот производный указатель. До записи он остаётся в raw source proof: внешний drift `.nextid` блокирует устаревший план. + +На 21 октября типизированный compiler и SQL/HTTP проверки реализованы. [Native lifecycle приёмка](mcp-chr-community-policy-acceptance.json) на двух CHR завершена 20 октября 19:44:22 UTC: exact IPv4/IPv6, subnet с диапазоном и одной длиной, set/append/clear, no-op без записи, удаление и совместная работа с существующей `bgp-in`. Все 22 fingerprints восстановлены. Это проверка синтаксиса и конфигурации. Передача Community, выбор маршрута и пакеты приняты отдельным двухузловым функциональным сценарием ниже; весь native harness ещё не завершён штатно. + +Предыдущий полный сценарий 20 октября остановился до BGP: [GRE control probe потерял 3/3 пакета](mcp-chr-bgp-community-overlay-loss.json), после чего все 22 baseline fingerprints восстановлены. [Последующая диагностика](mcp-chr-underlay-repeated-loss.json), завершённая 20 октября 19:53:06 UTC, не меняла конфигурацию и показала 2/9 ICMP с первого CHR на второй, 8/9 обратно при интервале 1 секунда, RPF=no на обоих. Это наблюдение доступности стенда в указанное время; оно не подтверждает Community propagation и не заменяет повторную сетевую приёмку после стабилизации пути. SSH-туннель закрыт. + +## Глобальные счётчики разделов + +`mm_sidebar_counts_get` (`system.sidebar.counts.read`) использует общий с `GET /api/sidebar-counts` сервис. Требуются явный `allowGlobal` и все права источников из каталога операции, включая settings/admin и чтение соответствующих разделов. Действует обычное наследование RBAC, в том числе settings/admin → users/read. Это сводка всех серверов; список `serverIds` не превращает её в сводку выбранной страницы. Для такого частичного чтения предназначены каталоги разделов. + +Перед сбором проверяется ограничение в 100 записей серверов, включая выключенные. Отказ в области доступа/правах и превышение лимита не вызывают native reads. Одновременные разрешённые запросы разделяют сбор и его фактические `startedAt`/`observedAt`. Данные разных источников не являются одной транзакционной фотографией: `atomicSnapshot: false`. + +`counts` содержит подтверждённые неотрицательные числа, включая настоящий ноль. Ошибка, неполный или недоступный дополнительный источник дают `null`; названия таких полей перечислены в `unknownCounters`, `incomplete: true`. Если основные SQL-счётчики не подтверждены, весь MCP-вызов завершается `STATE_UNAVAILABLE`. Исходные ошибки провайдеров не возвращаются. В REST неизвестные дополнительные поля по-прежнему отсутствуют, как до выделения общего сервиса. + +Подсчёт пользователей выполняется через SQL COUNT по тому же каталогу; карточки, права и привязки пользователей для этого не загружаются. Операция не создаёт планы и не меняет конфигурацию устройств. + +Актуальный срез: **328 инструментов; REST 168 mediated / 62 excluded / 32 pending из 262**. Новый инструмент не выдаётся существующим подключениям автоматически. Двухузловая функциональная Community-приёмка принята в объёме ниже; весь native harness и остальные незавершённые пункты полного плана остаются отдельными задачами. + +### Чтение выгрузок логов через MCP + +`mm_logs_export` создаёт зашифрованную CSV/JSON-выгрузку с маскированием секретов. `mm_logs_export_read` (`logs.export.read`) позволяет AI-клиенту получить её без браузерной сессии: передайте `artifactId`, затем повторяйте запрос с `offset`, равным предыдущему `nextOffset`, до `complete: true`. `limit` задаёт число исходных байтов, от 1 до 12000, по умолчанию 12000. Ответ содержит `dataBase64`, общий размер, SHA-256, имя, MIME и срок действия. Сначала соедините декодированные байты всех частей, проверьте размер/SHA-256, затем декодируйте UTF-8: граница части может проходить внутри символа. + +Оба инструмента требуют отдельного разрешения операции и `mm:servers:read`. Файл привязан к создавшему MCP-подключению, точному набору серверов, версии политики и поколению доступа. Подписанные метаданные проверяются до загрузки ciphertext; текущие права и grants проверяются повторно после SQL и расшифрования. Чужой файл, суженный grant, отзыв права, pause, истёкший срок или изменение подписанной области не позволяют получить содержимое. Старые файлы без подписанной области нужно экспортировать заново. Инструмент читает только такие выгрузки логов; backup/restore-архивы этим способом не выдаются. Существующий HTTP download продолжает требовать пользовательскую авторизацию. + +Общий REST/MCP экспорт ограничен 50000 событиями и 8 MiB: серверная область и фильтры применяются в SQL до проверки бюджета; превышение проверяется до передачи строк в Node. Проверяется также размер готового CSV/JSON. Сузьте период/фильтры при `EXPORT_LIMIT` (422). Одновременно на процесс допускаются два тяжёлых экспорта/чтения, без очереди; `EXPORT_BUSY` (429) предлагает повторить запрос позже. TTL файла — 15 минут. Для подключения допускаются 8 файлов и 24 MiB ciphertext, общая квота — 256 MiB ciphertext; проверка и сохранение атомарны под PostgreSQL-блокировкой. При `EXPORT_QUOTA_EXCEEDED` (429) дождитесь истечения файлов. Эти ограничения также учитывают прочие записи хранилища артефактов. REST CSV сохраняет колонку host; MCP сохраняет прежнее маскирование и формат колонок. + +Повторный [контроль underlay](mcp-chr-underlay-community-preflight.json) 20 октября 20:08:54 UTC прошёл без потерь: 9/9 пакетов в каждую сторону, без изменения конфигурации. На этом основании выполнен новый изолированный сценарий. На первом узле прошли IPv4/IPv6, на втором IPv4 вернул unknown без подтверждённых счётчиков; BGP-этап не начинался. Первичная очистка остановилась на STATE_CONFLICT. Отдельная [очистка точного маркера](mcp-chr-overlay-recovery.json) завершилась 20 октября 20:15:11 UTC: все 22 исходных fingerprints восстановлены, новый fixture не создавался, SSH закрыт. [Неуспешный прогон](mcp-chr-bgp-community-interrupted-overlay.json) сохранён; Community propagation остаётся открытой. + +Контрольный ping для допуска Route AI теперь требует завершённых счётчиков именно 3 отправленных пакетов. Ответ 1/1 или 2/2 не подтверждает путь даже при нулевой заявленной потере. Для unknown добавлены фиксированная причина и ограниченные числовые наблюдения счётчиков, без исходных сообщений устройства; непроверенная трасса отмечается TRACE_UNCONFIRMED. Подтверждённый отказ по-прежнему требует 3/0. Эти проверки принимаются отдельно от фактического Community forwarding на стенде. + +После усиления допуска пути полный повтор 27 backend-профилей, MCP SQL/HTTP, real pg_dump/restore и SQL logs завершён успешно 21 октября. [Сводная проверка](mcp-path-completion-acceptance.json). Для следующих native-запусков тестовый отчёт сохраняет стадии prepare/approval/apply/verification/terminal, planId/operationId и безопасный код ошибки; аргументы и ответы провайдеров не сохраняются. Изменение тестового журнала проверено сборкой и линтером, отдельный native-прогон с ним ещё не принят. + +[Приёмка выгрузок логов](mcp-log-artifacts-acceptance.json): полная регрессия 27 профилей, SQL/HTTP, настоящий pg_dump/restore, source/row/byte/quota guards и гонки отзыва доступа прошли. Native и внешние AI-клиенты этим отчётом не принимаются. + +Последняя native Community-диагностика 21 октября: получатель действительно увидел `64512:999`, но после входного изменения gateway маршрут в VRF стал `unreachable` без `immediate-gw`. Успех конфигурационного read-back не подтверждает forwarding. [Отчёт с ограниченным RIB-снимком](mcp-chr-bgp-community-unreachable-diagnostic.json): обе BGP-сессии установлены, все 22 исходных fingerprints после очистки совпали; append/clear, gateway/blackhole и полная Community-приёмка остаются открытыми. Значение `immediate-gw` и флаги RIB описаны в [официальной документации MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/59965493/routing%2Broute). Конкретная причина неразрешения next-hop пока не установлена. + +[Приёмка публикации restore-квитанций](mcp-state-publication-acceptance.json): при временном Windows sharing denial ограниченно повторяется только атомарная замена уже подготовленного файла, без повторного pg_restore. Старый файл остаётся доступным. Реальная блокировка Windows, 27 backend-профилей и настоящий restore проверены. Точная причина прежнего исторического сбоя без OS-кода не утверждается; test-runner теперь фиксирует безопасные stage/code. Изменение порядка VRF отменяет старый MCP filter-план до записи. Синтаксис `gateway@VRF` для routing-filter отклонён CHR 7.24.5 и в рабочий компилятор не включён. + +[Защита cross-table gateway references](mcp-cross-table-dependency-acceptance.json): MCP учитывает IPv4/IPv6 gateway `@table`/`@VRF` при delete/rename/disable. Зависимый маршрут в `main` блокирует удаление VRF, даже если его собственная routing-table другая. Kernel dynamic self-routes не блокируют штатное удаление членства; настроенные static routes блокируют. В том числе проверяется появление ссылки после approval. Полная backend-регрессия 27 профилей, SQL/HTTP и настоящий restore прошли. [Recovery marker581cb8ad](mcp-chr-community-login-recovery.json) подтвердил все 22 исходных fingerprints без нового fixture. Автоматический Route AI для non-main таблиц по-прежнему явно отклоняется и остаётся в незавершённой части плана. + +[Двухузловая Community-приёмка](mcp-bgp-community-dual-afi-acceptance.json): IPv4/IPv6 set/append/clear, оба tag после append, positive native blackhole, AFI isolation и восстановление подтверждены 18 packet proofs (12×3/3 с трассой, 6×3/0). Исходный полный harness завершился ошибкой RouterOS 401 при BGP teardown. [Отдельное recovery](mcp-chr-community-dual-afi-recovery.json) восстановило все 22 fingerprints; SSH закрыт. Весь harness со штатной очисткой, независимые пути и автоматический VRF Route AI этим отчётом не принимаются. + +`mm_netflow_talkers_get` (`traffic.flow.talkers.read`, `mm:traffic:read`, server scope) читает основные разговоры из сохранённых NetFlow rows и pending. Общая REST/MCP проекция объединяет conversation keys, сохраняет точные bytes/packets и configured topN; offset/limit относятся к доступному top-списку, totalConversations — ко всем разговорам в выбранном retained окне. Scope/grants применяются до SQL и разбора счётчиков; 50000 строк на набор выбранных серверов, 1–1440 минут, до 10 серверов/200 items на страницу. История выключенного сервера доступна без опроса устройства. Глобальные runtime/listener/host сведения не выдаются. Ответ явно сохраняет incomplete/coverageVerified=false: retained ingress rows могут пересекаться по интерфейсам, bps — усреднение по всему окну; это не wire rate и не подтверждение forwarding. Поздний отзыв прав, отмена, pause и изменение источника/topN блокируют выдачу. [Приёмка](mcp-netflow-talkers-acceptance.json): SQL/HTTP, лимиты, реальные гонки доступа, REST parity, build/lint и все 27 профилей прошли. + +`mm_alerts_outbox_retry_plan` (`alerts.outbox.retry`, `mm:alerts:write`, global scope) повторно ставит существующее unknown-уведомление Telegram в очередь. Нужны точные id/expectedVersion, acknowledgedDuplicateRisk=true и отдельное подтверждение человеком. План сохраняет исходные текст/адресата; новый текст или получатель через MCP не задаются и не возвращаются. Требуется весь текущий каталог серверов (предел источника 999; лимиты подключения могут быть ниже), включая явный global allow. Общий REST/MCP application guard учитывает protections. Приватный HMAC исходной записи (до 1 MiB) повторно проверяется под блокировкой, затем CAS и read-back проходят в одной транзакции. Изменение адресата/payload при записи откатывает транзакцию до доступности сообщения отправителю. + +Подтверждение retry означает только постановку в очередь: операция applied, verification=inconclusive, verificationScope=acknowledgement. Фоновый отправитель может выполнить её позже; прежнее сообщение могло быть доставлено, поэтому повтор создаёт риск дубликата. После unknown результата требуется сверка состояния, а не автоматический повтор. С тем же idempotency key возвращается прежняя квитанция. Проверка подключения и права на новую запись не заменяют друг друга: ранее созданная квитанция может оставаться доступна после отзыва write-права, но новое изменение отклоняется. [Отчёт приёмки](mcp-alerts-outbox-retry-acceptance.json). + +OSPF cost writer усилен: ожидаемый исходный cost, приватная привязка endpoint/учётных данных изменяемого роутера для optimizer-плана, сверка актуального источника под application/router lock, два bounded raw GET и проверка всей коллекции native templates после PATCH, включая не редактируемые UI поля. Игнорирование записи, подмена полей и потеря ответа останавливают цепочку без автоматического повтора. `routing.ospf.optimize` имеет revision=2; после обновления операции старый план следует подготовить заново. Runner также проверяет уже queued старые ревизии. REST различает известный отказ до записи и unknown после записи. + +[Исторический OSPF-блок](mcp-ospf-cost-write-progress.json) был остановлен лимитом автоматической проверки разрешений. После восстановления лимита прошли SQL/HTTP и полная регрессия, затем общий legacy ranker/apply был подключён к публичным MCP apply/restore: [актуальная приёмка](mcp-ospf-legacy-actions-acceptance.json). Формула измеряемого optimizer v7 остаётся отдельной. Приёмка новых публичных legacy-команд на реальных CHR ещё требуется; успешный SQL-прогон её не заменяет. + +После восстановления лимита обычная проверка разрешений разрешила повтор OSPF-регрессии: все 27 backend-профилей, latest SQL/HTTP primary credential/revision/old-queued guards, настоящий pg_dump/restore, tampered archive и SQL logs прошли с кодом 0. [Принятая часть](mcp-ospf-cost-write-acceptance.json) относится к shared cost-only writer и привязке изменяемого роутера; exact REST preparation adapter, peer/source graph и native CHR проверка этой версии остаются открытыми. Прежняя блокировка review снята штатно, без обхода. + +Замеры маршрутизации теперь привязаны к обоим зарегистрированным устройствам и эпохе подключения (миграция0029). После изменения host/port/учётных данных/TLS/enabled нужен новый замер, даже если вернуть прежние параметры. Старые строки остаются в истории с `sourceVerified=false`; они не участвуют в оптимизации. Новые строки подписывают метрики, интерфейсы, family/table и время публикации с микросекундами. Внешняя замена базы/поколения доступа также делает старые подписи непригодными. + +История возвращает `sourceVerified`, `contextVerified` и `recordedAt`; `serverId` фильтруется в SQL внутри разрешённой области обоих концов. Секреты и подписи в историю не выдаются. Изменение источника или отзыв доступа во время измерения, ignored/tampered INSERT и неполные счётчики ping не создают пригодных данных. Последний unknown не заменяется более старым успехом. Для IPv6 можно явно выбрать `dstAddress` на интерфейсе зарегистрированного peer; глобальный bandwidth-test по-прежнему не доказывает выбранный WAN. + +Текущие ревизии: `routing.ospf.optimize=3`, `diagnostics.bandwidth=2`; старые планы необходимо подготовить заново. [Backend-приёмка](mcp-routing-source-acceptance.json) прошла с повторной проверкой отдельного logs-профиля. Native OSPF-приёмка этих ревизий прервана перезапуском второго CHR до OSPF preflight. Все owned объекты удалены; точное совпадение всех исходных baseline не подтверждено: у двух динамических адресов доказана только смена ID, две route baseline требуют отдельной сверки. [Отчёт очистки](mcp-chr-ospf-source-cleanup.json). Полный REST optimizer adapter и граф уникальности Router ID остаются открытыми. + +Текущая ревизия `routing.ospf.optimize=4` связывает весь граф владельцев в области MCP-политики, включая невыбранные устройства: endpoints/эпохи/поколение доступа и native OSPF instances. Все кандидаты входят в target, лимит устройств и блокировку. Повторная проверка перед dispatch отменяет план при появлении дубликата Router ID, изменении каталога или источника. Отключённый опрос Manager не доказывает отсутствие роутера в OSPF: такой источник не пропускается молча, подготовка отклоняется до сетевого опроса. + +План revision3 и ранее необходимо подготовить заново. Приватные HMAC не выдаются в preview/audit. Лимиты графа:1000устройств,10000native rows суммарно,1МиБ. [Backend-приёмка revision4](mcp-ospf-owner-graph-acceptance.json):27профилей, настоящий restore, три SQL/HTTP-роутера, реальная блокировка третьего кандидата и смена его пароля во время native HTTP-ответа. Exact REST preparation, контекстное разрешение одинаковых Router ID между family/VRF и native current-revision проверка остаются открытыми. + +Ревизия `routing.ospf.optimize=5` разрешает именованный Router ID (`main` и другие ссылки) через `/routing/id`, сверяя его фактический static/elected ID и disabled/inactive. RouterOS допускает IP либо имя ссылки в `router-id`; выбранное значение ссылки доступно как `dynamic-id`. [Описание OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%20ospf), [описание Router ID](https://help.mikrotik.com/docs/spaces/ROS/pages/59965506/routing%2Bid). + +Пропущенные параметры уточняются у устройства. Исходная конфигурация и effective identity запечатываются раздельно; смена выбранного ID после approval отменяет план до cost PATCH. Владельцы разделяются по OSPFv2/v3, а набор нужных соседей ограничен выбранными локальными instance/interface. Неизвестный источник не пропускается. Полное различение одинаковых Router ID между удалёнными VRF ещё требует двусторонней привязки соседства; имена VRF двух роутеров не считаются равными автоматически. + +[Backend-приёмка revision 5](mcp-ospf-identity-acceptance.json): все 27 профилей, SQL/HTTP с elected ID/версией/defaults/соседним локальным VRF и настоящий restore прошли. [Read-only проверка CHR](mcp-chr-ospf-identity-readonly.json) подтвердила реальный источник именованных ID без native записей, используя синтетический OSPF-потребитель при пустых реальных OSPF catalogs. Полная native OSPF cost/forwarding приёмка и точный REST-сценарий остаются открытыми. + +Ревизия `routing.ospf.optimize=6` определяет peer VRF по фактическому двустороннему Full и принадлежности обоих адресов нужным интерфейсам. Равенство названий VRF на устройствах не требуется. Изолированное повторение Router ID допустимо; несколько полностью подходящих контекстов возвращают `MEASUREMENT_UNKNOWN`. Preview указывает peer instance/VRF/interface, а метрика должна относиться к этому peer-интерфейсу. + +Граф identities/neighbors/addresses проверяется до dispatch и после cost-записи. Если запись подтверждена, но соседство изменилось во время неё, результат `partial` требует сверки; тот же idempotency key не повторяет PATCH. В подготовке проверяются живой допуск, отмена и source drift между native чтениями. IPv6 scope и effective address flags проверяются, счётчики adjacency исключены из identity fingerprint. + +[Backend-приёмка revision 6](mcp-ospf-vrf-link-acceptance.json) включает 27 профилей и дополнительную финальную MCP SQL/HTTP+restore проверку после post-write guard. [Read-only проверка CHR](mcp-chr-ospf-link-readonly.json) читала настоящие источники адресов/флагов и Router ID без записей. При пустых реальных OSPF catalogs она не доказывает работающее соседство/cost/forwarding. Полная native приёмка, unnumbered-случаи без адресного доказательства, точный REST-сценарий и оставшиеся пункты плана не закрыты. + +Обратный OSPF-план использует `operations.recover=2`: expected cost берётся из подтверждённого снимка после исходного шага. Сохраняются private source proofs и полный граф соседств; потеря peer-контекста отменяет обратную запись. Старые версии исходного действия и старые обратные планы, включая очередь, требуют новой сверки. Прямой и обратный PATCH, late peer drift, idempotency и version fences проверены через SQL/HTTP; все 27 backend-профилей прошли. + +Неуспешная [native приёмка](mcp-chr-ospf-v6-inverse-failure.json) подтвердила OSPFv2/v3 Full+BFD Up и IPv4 cost, затем выявила ошибку expected cost в inverse. Все 22 исходных снимка после очистки совпали. Повторная [native приёмка исправления](mcp-chr-ospf-v6-cost-inverse-acceptance.json) завершилась exit 0: 60 MCP-операций, четыре Full/BFD Up, cost/inverse для IPv4/IPv6, 3/0 при отказе и 3/3 после отдельного одобренного восстановления; все 22 снимка совпали, туннель закрыт. У восстановленной IPv6-трассы один hop неизвестен при подтверждённом конечном узле; полная трасса не заявляется. [Сводная приёмка](mcp-ospf-inverse-acceptance.json). GRE-пробы к соседнему адресу подтверждают connected-путь; пересылка по изученным OSPF-маршрутам остаётся отдельной открытой проверкой. + +### Пассивные шаблоны OSPF и revision 7 + +`routing.ospf.optimize` revision 7 допускает только подтверждённые непассивные шаблоны. Пассивный loopback не требует BFD для ранжирования рабочего GRE и не становится целью cost-записи даже при устаревшем Full. Неизвестный `passive` не считается false. RouterOS switch подтверждается native getter либо двумя полными взаимодополняющими presence queries; лимиты, отзыв доступа и source drift проверяются между чтениями. После обновления старый cost-план необходимо подготовить заново. + +Режим стенда `MMAPP_TEST_CHR_OSPF_FORWARD=true` требует `OSPF_SOURCES`, `OSPF` и `OVERLAY`, использует разные локальные VRF, дополнительные loopback-префиксы и проверку активного изученного RIB-маршрута. Маршрут и метрика проверяются до cost, после cost и после inverse, затем выполняются MCP packet probes. Обратный путь проб идёт по connected GRE; независимые пути не заявляются. [Предыдущая приёмка](mcp-chr-ospf-forward-passive-bfd-failure.json) подтвердила начальные маршруты и 3/3 в обоих семействах/направлениях, затем остановилась до cost; все 24 baseline восстановлены. Полная повторная [приёмка revision 7](mcp-ospf-forward-acceptance.json) завершилась exit 0: 86 операций, 16 изученных RIB и 16 проб 3/3 с полными трассами, passive обоих семейств, все 24 исходных снимка и закрытый SSH-туннель. + +`mm_ospf_route_proof_get` (`routing.ospf.route.proof.read`, право `mm:network:read`) принимает явные `serverId`, `prefix`, `table` и `interface`. Возвращает ограниченные поля одного активного изученного маршрута, подтверждённую метрику и время получения backend. Семейство определяется по префиксу; неподтверждённый протокол, флаги, метрика, неоднозначность и иной immediate egress дают отказ. На сервер проверяется область доступа до опроса; между GET/getter и перед выдачей проверяются допуск, отмена и epoch источника, включая ABA. Raw RIB и непрозрачные поля не возвращаются и не попадают в журнал. `forwardingVerified=false`: для доставки нужны отдельные packet probes. Новое право операции необходимо явно выдать подключению. SQL/HTTP, настоящий restore, tampered archive, все 27 backend-профилей, build, types и ESLint прошли. [Приёмка reader](mcp-ospf-route-proof-acceptance.json). + +Шаблоны OSPF create/update используют revision 2. `passive=false` при создании опускает switch с обязательным чтением результата; при обновлении флаг снимается native `/unset`. Смешанные поля превращаются в отдельные шаги плана, включая обратное восстановление. [Native switch-приёмка](mcp-chr-ospf-passive-v2-switch-acceptance.json) прошла для OSPFv2/v3: 22 операции и все шесть исходных снимков восстановлены. + +Проверка BFD в optimizer выполняется для непассивных шаблонов с единственным Full-соседом в выбранном instance/table: это уже обязательное условие ранжирования. Поэтому посторонний LAN без Full и с неизвестным BFD больше не блокирует рабочий GRE. Неизвестный BFD у подходящего Full-кандидата по-прежнему даёт отказ. Совместимое уточнение revision 7 проверено SQL/HTTP, модельными тестами и всеми 27 backend-профилями; отдельный native сценарий LAN без Full не заявляется. + +### Удаление peer WireGuard + +`mm_wireguard_peer_remove_plan` (`network.wireguard.peers.delete`, `mm:network:write`) готовит человеческое подтверждение удаления одного peer по стабильному native ID. REST DELETE и native alias `ros.wireguard_peer.delete` revision 2 используют тот же сервис. Старые native планы требуют подготовки заново. Проверяются защиты сервера и родителя, привязки пользователей к peer/целому интерфейсу и фактическое владение NetFlow; одно имя интерфейса не доказывает владение сервисом. + +Приватный HMAC фиксирует источник, видимую конфигурацию интерфейсов/peers и зависимости. Ключи в ответ и журнал не передаются. Перед единственным DELETE проверяется отсутствие дрейфа; после него — сохранность родителя и остальных peers. Счётчики трафика не считаются изменением конфигурации. Потеря ответа оставляет `unknown`, неподтверждённое или побочное изменение — `partial`; повторной native записи нет. Результат отдельно сообщает, сохранена ли ревизия приложения. Универсального inverse удаления нет. + +`mm_operation_reconcile` поддерживает этот сервис через запечатанные исходный и желаемый HMAC: читает состояние с проверкой допуска/отмены между запросами и возвращает `matchesDesiredState` / `matchesOriginalState`. Сверка не меняет состояние исходной операции, включая `unknown`, и не повторяет DELETE. Старый план без совместимого desired proof получает явный отказ. + +[Приёмка](mcp-wireguard-peer-delete-acceptance.json): все 27 backend-профилей, SQL/HTTP, настоящий restore и tampered archive, build и ESLint прошли. На CHR выполнены четыре MCP-операции с отключёнными собственными интерфейсом и peer; подтверждены один DELETE, отдельная read-only сверка, сохранность родителя и запись ревизии. Все четыре исходных снимка и приватный отпечаток восстановлены, адреса не менялись, SSH-туннель закрыт. Handshake, пользовательский трафик и внешние AI-клиенты этим сценарием не проверяются. + +[Cost/forward-приёмка revision 7](mcp-ospf-forward-cost-acceptance.json) отдельно подтвердила 12 изученных RIB/packet proofs, метрики 60 → 40 → 60, разные имена VRF и 24 восстановленных baseline. Полный [исходный запуск](mcp-chr-ospf-v7-forward-passive-toggle-failure.json) завершился ошибкой на последующем passive toggle; это не успешный полный harness. Новый полный [запуск с публичным MCP reader и исправленным switch](mcp-chr-ospf-v7-forward-switch-acceptance.json) завершился exit 0: 86 операций, 16 RIB/packet proofs, cost/inverse/passive для IPv4/IPv6 и восстановление всех 24 baseline. Проверен MCP runtime с реальным RouterOS API; внешние AI-клиенты остаются отдельной приёмкой. Обратный путь контрольных проб — connected GRE; независимые транзитные и пользовательские пути не заявляются. + + +При native `mm_operation_reconcile` допуск и отмена проверяются до каждого snapshot/getter и перед выдачей; изменение источника, включая endpoint ABA, прерывает чтение. Проверяются также источники предыдущих native шагов перед общей выдачей. `matchesVerifiedCheckpoint` сравнивает видимую конфигурацию ресурса: `comparisonScope=visible-resource-configuration`. Это не удостоверение исходного устройства (`originalSourceIdentityVerified=false`) и не доказательство forwarding. Отдельный WireGuard service proof включает исходный источник. Проверки отмены/паузы/ABA используют изолированные SQL/HTTP сервер и подключение; исходная операция не меняется и запись не повторяется. + +[Приёмка native-сверки](mcp-native-reconcile-fences-acceptance.json): изолированный SQL/HTTP-сценарий, все 27 backend-профилей, SQL logs, настоящий restore, tampered archive, build и ESLint прошли. Новая проверка native-сверки на реальном RouterOS отдельно не заявляется; live WireGuard service reconciliation зафиксирована в собственном отчёте выше. + + +### Изменение peer WireGuard + +`mm_wireguard_peer_edit_plan` (`network.wireguard.peers.update`, `mm:network:write`) принимает `serverId`, стабильный `peerId` и непустой строгий `patch`. Поддерживаются все 11 публичных полей REST: publicKey, allowedAddresses, endpointAddress, endpointPort, persistentKeepalive, comment, name, disabled, clientAddress, clientDns, clientEndpoint. Пустые строки передаются как очистка; приватные и preshared keys не принимаются. REST PATCH и native update alias revision 2 используют один сервис. Перенос peer на иной интерфейс не входит в этот PATCH и получает явный отказ. + +План требует человека, запечатывает источник, видимую непрозрачную конфигурацию, привязки и NetFlow-контекст. Разрешены обычные изменения привязанного peer; смена его публичного ключа требует предварительного изменения привязки и не может присвоить чужую dangling-привязку. Защиты родителя и сервера обязательны. Выполняется один PATCH с проверкой сохранности остальных peers и приватных полей. Потеря ответа оставляет `unknown`, неподтверждённый эффект — `partial`; тот же ключ идемпотентности не повторяет запись. Ревизия приложения возвращается отдельным receipt, универсального inverse сервиса пока нет. + +Keepalive на проверенном CHR после отключения отсутствует в списке, а native getter возвращает `[]`. Одного отсутствия недостаточно: reader требует успешный nil-getter, полное разбиение всех peer ID двумя противоположными presence-запросами и совпадение приватного снимка после вспомогательных чтений. В proof различаются неподтверждённое отсутствие и подтверждённое отключение; отсутствующие числовые значения не выдаются как измеренный ноль. Явный 0 отключает keepalive согласно [документации WireGuard](https://www.wireguard.com/quickstart/); диапазон и default RouterOS описаны в [справочнике MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/69664792/WireGuard). Допуск, отмена и source epoch проверяются между native чтениями. `mm_operation_reconcile` сравнивает исходный/желаемый semantic proof без записи и изменения состояния исходной операции. + +Первые native clear-запуски дали `partial`, корректно остановились и восстановили все четыре baseline; они сохранены отдельно. Исправленная CHR-приёмка завершилась exit 0: шесть applied операций, два PATCH, все 11 полей, очистка endpoint/port/keepalive, read-only сверка, один DELETE peer и восстановление четырёх снимков/приватного отпечатка. Интерфейс и peer были отключены, адреса не менялись, туннель закрыт. Приёмка handshake/пользовательского трафика и внешних AI-клиентов этим сценарием не заявляется. Финальный полный backend-прогон после исправления прошёл: все 27 профилей, SQL logs, настоящий restore и tampered archive; build/ESLint и diff-check успешны. [Итоговая приёмка PATCH](mcp-wireguard-peer-update-acceptance.json). + +### Удаление интерфейса WireGuard с зависимыми объектами + +`mm_wireguard_interface_remove_plan` (`network.wireguard.interfaces.delete`, revision 1) подготавливает человеческий план для `serverId` и стабильного `interfaceId`. REST DELETE использует тот же сервис. Preview показывает упорядоченные команды и их стоимость: отключение включённого интерфейса, удаление peers, статических IPv4/IPv6-адресов и самого интерфейса последним. Лимит — 50 native-команд; запись ревизии учитывается отдельно. Native alias `mm_wireguard_delete_plan` revision 2 сохраняет удаление только пустого интерфейса и отказывается от каскада. + +Привязки пользователей, NetFlow, защиты сервера/родителя/детей и ссылки в маршрутах, фильтрах, протоколах и интерфейсах блокируют удаление. Неизвестные состояния дают отказ. Проверка включает literal и RouterOS-escaped ссылки, IPv6 и неявные OSPF network selectors. Перед каждой командой заново проверяются допуск, источник, зависимости и приватный отпечаток конфигурации; после каждой команды требуется точное подтверждение ожидаемого состояния. Runtime-таблицы зависимостей читаются, но не выдаются за запечатанную статическую конфигурацию. + +Потеря ответа даёт `unknown`, неподтверждённый либо посторонний эффект — `partial`; запись не повторяется. Read-only reconciliation сравнивает исходное, конечное и подтверждённые промежуточные состояния, сохраняя статус исходной операции. Универсальный inverse и автоматическое продолжение каскада не поддерживаются. + +[Приёмка DELETE](mcp-wireguard-interface-delete-acceptance.json): на CHR шесть applied MCP-операций, пять DELETE с проверкой после каждой команды, два peer и два статических адреса, подтверждённый отказ native alias от каскада. Все четыре исходных снимка и приватный отпечаток восстановлены, туннель закрыт. Родитель и адреса fixture были отключены. Полный прогон 27 профилей, SQL logs и настоящий restore/tamper прошли; после последнего уточнения UTF-8 parser отдельно прошли SQL/HTTP/restore, модель, build и ESLint. Forwarding, VPN handshake и внешние AI-клиенты этим сценарием не принимаются. + +### NetFlow: подробная аналитика в MCP + +`mm_netflow_analytics_report_get` (`traffic.flow.analytics.read`, `mm:traffic:read`) использует общий REST analytics engine для выбранного `serverId`. Поддерживаются окно 1–1440 минут, user/interface filters, dedup/excludeMesh/excludeOverlay и страницы массивов. Это отдельный инструмент; прежний краткий `mm_netflow_analytics_get` сохранён. Новое разрешение не добавляется существующим подключениям автоматически. + +Scoped режим задаёт сервер до SQL и pending-выборки, обходит общий REST-кэш, загружает только разрешённую топологию и исключает пользователей со скрытыми привязками. Peer-привязка не считается владением всем интерфейсом. Источник ограничен 20000 строками, страница — 100 элементами, ответ — 1 МиБ; topN источника и top-списки engine явно показаны. Для чтения лимита не загружаются настройки с ключами коллектора и не выполняется их инициализация. Будущие строки исключены из окна общего analytics engine. + +Проверки доступа и источника выполняются между этапами чтения и перед выдачей; топология и topN сверяются повторно. MCP не запускает коллектор, RIPE-enrichment, native poll или запись конфигурации. Сохранённые суммы неполны по определению retained topN. Context rates вынесены отдельно как унаследованные REST-оценки с ring/window fallback, без подтверждения пользовательской атрибуции. Paths — предположение по текущей топологии; forwarding, историческое владение и end-to-end пути не объявляются подтверждёнными. + +[Приёмка аналитики](mcp-flow-analytics-acceptance.json): SQL/MCP runtime и сравнение с REST engine, скрытые серверы/пользователи/peer, изоляция кэша, filters/dedup/pages/future/limits, endpoint ABA, drift топологии, cancel/pause и отсутствие планов. Отдельный REST HTTP-запрос в новом сценарии не выполнялся. Полная регрессия 27 профилей, SQL logs, настоящий restore/tamper, build и ESLint прошли. CHR forwarding и внешние AI-клиенты остаются отдельными пунктами плана. + +### Карта NetFlow: предполагаемые связи и сервисы + +`mm_netflow_map_hops_get` (`traffic.flow.map.read`, `mm:traffic:read`) использует общий REST map engine для обязательного `serverId`. Поддержаны окно 1–1440 минут, фильтры user/interface, dedup/excludeMesh/excludeOverlay. Основные массивы имеют `offset`/`limit` до 100; вложенные clients, country services/edges/paths — отдельные `nestedOffset`/`nestedLimit` до 50. `pages` содержит total/nextOffset каждого выданного массива. Ссылки на узлы могут выходить за страницу; граф не объявляется полным. + +Сервер ограничивает SQL и pending до загрузки потоков. Топология включает только разрешённые серверы и пользователей без скрытых привязок; peer-only привязки не считаются всем интерфейсом. Общий кэш обойдён, фоновое enrichment не запускается, topN и порог карты читаются без ключей коллектора. Между чтениями проверяются доступ и источник; перед выдачей повторно сверяются топология и настройки. Лимиты: 20000 строк и 1 МиБ ответа; будущие строки исключены из окна общего map engine. Порог карты задаётся настройками и не переопределяется MCP-запросом. + +Карта выводит предположительные связи из retained потоков и текущей топологии. `live=false`, `inferredPathsVerified=false`, `forwardingVerified=false`, `graphComplete=false`; наличие listener не подтверждает свежесть потоков или доставку. Traceroute и native poll не выполняются. Историческое владение интерфейсами и полное покрытие сети не подтверждаются этой выдачей. + +[Приёмка карты](mcp-flow-map-acceptance.json): настоящий маршрут REST через Fastify.inject и изолированную SQL БД, совпадение totals/services/hops; отдельно проверены скрытые серверы/пользователи/peer/pending, общий кэш со скрытым именем, вложенные страницы, filters/dedup/future/row limit, source ABA, topology/threshold drift и cancel/pause, отсутствие планов. Полная регрессия 27 профилей, SQL logs, настоящий restore/tamper, build и ESLint прошли. CHR forwarding и внешние AI-клиенты остаются отдельными пунктами. + +### Файлы коллектора NetFlow: подтверждаемый экспорт + +`mm_netflow_host_files_export_plan` (`traffic.flow.hostFiles.export`, `mm:traffic:write`) создаёт зашифрованный JSON-пакет через общий REST builder. Требуются global scope, все серверы приложения и человеческое подтверждение. MCP получает metadata/downloadPath/SHA-256; содержимое с приватным ключом не возвращается через MCP и не попадает в аудит. Пакет доступен 15 минут только вошедшему владельцу с правом настройки NetFlow. MCP bearer не заменяет JWT портала. + +Подписаны назначение, credential, policy/generation, источник, заголовок, срок и hashes. Источник включает точный набор серверов и сформированные файлы; смена конфигурации или ключа блокирует скачивание. Проверки выполняются до загрузки шифротекста, до расшифровки и перед выдачей. После создания отдельно проверяются сохранённый шифротекст и актуальность источника. Лимиты: 1 МиБ plaintext, 2 МиБ ciphertext и общая квота временных артефактов. Установка и применение файлов не выполняются. + +Общий HTTP download теперь проверяет подписанный тип и hashes логовых артефактов: подмена секретного пакета под видом логов не даёт получить ключ через другой канал. Неподписанные старые артефакты требуют повторного формирования. + +[Приёмка](mcp-host-files-acceptance.json): approval/full scope, owner-only bundle, настоящий JWT HTTP и REST parity, отклонение MCP bearer/no-auth, подмена header/kind/cipher и cross-purpose log HTTP, stale source, idempotency и отзыв после чтения шифротекста. Полный прогон 27 профилей/SQL logs/restore/tamper прошёл до последнего уточнения read-back; финальные целевые SQL/HTTP/restore-сценарии завершились по журналу, отдельный exit code последнего процесса не наблюдался. Финальные build, lint и coverage прошли. Native установка и внешний AI-клиент этим сценарием не принимаются. + +### Настройка Telegram через MCP + +`mm_alerts_telegram_update_plan` (`alerts.telegram.update`, `mm:alerts:write`) принимает строгий непустой набор `settings` (chatId/messageThreadId) и/или `secretRef`. Требуются global scope, все серверы приложения и человеческое подтверждение. Новое разрешение существующим подключениям автоматически не выдаётся. + +Пропущенные поля сохраняются; `messageThreadId=null` очищает тему, `secretRef=null` очищает токен. Для нового токена нужна ссылка на глобальный секрет владельца с purpose `alerts-telegram-bot`. Сырой token в MCP-запросе не принимается. Токен не возвращается в preview, результате и аудите. Перед подтверждением запечатываются приватный снимок/updatedAt, каталог и ссылка; перед записью повторно проверяются доступ, конфигурация и ссылка на секрет. Общий application lock и guarded selective-update используются также REST: защищённый объект `alerts-telegram:1` нельзя изменить в обход плана. Результат подтверждается чтением сохранённых полей. + +Настройка сама не отправляет сообщений и не проверяет доступность бота. Обратное изменение требует нового плана; прежний токен после замены не выдаётся. [Приёмка](mcp-telegram-settings-acceptance.json): SQL/runtime и REST, preserve/clear/thread, owner/purpose refs, strict input, full scope/human approval, stale source/ref, REST protection, cancel/pause, idempotency и отсутствие токена в preview/audit. Все 27 профилей, SQL logs, real restore/tamper, build/lint прошли. POST тестовой отправки завершён следующим этапом ниже; внешняя Telegram-доставка отдельно не принята. + +Этот же полный прогон повторно прошёл финальный verifier шифротекста/источника NetFlow host-files и JWT/cross-purpose HTTP-сценарии. Он дополняет исторический протокол экспорта, где exit code последнего отдельного HTTP-прогона не наблюдался; историческое поле не переписано. + +### Тестовая отправка Telegram + +`mm_alerts_telegram_test_plan` (`alerts.telegram.test`, `mm:alerts:write`) требует full global/server scope и человеческого подтверждения. План показывает адресат, тему и точный текст. Используются сохранённые параметры либо owner/purpose secretRef; сырой token в MCP не принимается. Ссылка использует тот же purpose `alerts-telegram-bot`. Rule preview и текст ограничены по размеру. Перед отправкой сверяются приватный источник, ссылка и точное совпадение payload с подтверждённым preview. + +Общий REST/MCP builder сохраняет fallback параметров и формат сообщения. На попытку выполняется один POST к фиксированному API Telegram, без redirect и автоматического повтора; ответ ограничен 64 КиБ, timeout — 30 секунд. REST также проходит общий guard защищённого test-объекта. Положительный ответ API — только acknowledgement (`deliveryVerified=false`, verification inconclusive). Явный отрицательный API receipt сохраняет `outcome=rejected` и partial; потеря, malformed или oversized ответ даёт unknown. Повтор с тем же idempotency key не отправляет снова. Новый тест после unknown может создать дубликат и требует нового человеческого подтверждения. + +Настройки и история срабатываний не меняются. Уже отправленный HTTP-запрос нельзя отозвать; пауза блокирует дальнейшие проверки/отправки и может оставить результат неизвестным. Токены и provider error descriptions в результат MCP не передаются. + +[Приёмка](mcp-telegram-test-acceptance.json): настоящий локальный HTTP provider и REST payload parity, одно обращение/идемпотентность, ref/override, accepted/rejected/lost/malformed/oversized, source drift после intent до HTTP, защита REST, schema/cancel/pause и отсутствие токена в аудите. Все 27 профилей, SQL logs и real restore/tamper прошли до последней preview-сверки; после неё отдельно прошли финальные SQL/HTTP/restore, build и lint. Несовпадение текста/адресата с preview проверено с нулём отправок. Настоящий Telegram не вызывался; внешняя доставка не принята. + +## Legacy OSPF: применение модели приложения и отдельное восстановление + +`mm_ospf_application_optimize_plan` принимает `serverId` и `pingWeight` (0–100, по умолчанию 60). Использует настоящий общий REST ranker/apply; выбранная модель сохраняет округления, минимумы и fallback. Требуются `mm:network:write`, допуск ко всему каталогу приложения (не более 10 серверов) и подтверждение человеком даже при autonomous-подключении. В preview показаны конкретные from/to cost; до 50 записей выполняются последовательно. Этот инструмент не заменяет измеряемый optimizer v7 и не подтверждает forwarding, Full/BFD или свежий bandwidth. + +План связывает SQL-источники, native OSPF-конфигурацию, opaque поля, подключения/epoch, права и защиты private HMAC. Они проверяются перед PATCH после durable intent и после read-back под application/router locks. Источники или защиты изменились — нужен новый план. Игнорирование PATCH, потеря ответа и посторонние изменения останавливают следующие записи; автоматического повтора нет. Receipt содержит `nativeWrites` отдельно от числа сервисных шагов и `forwardingVerified:false`. `mm_operation_reconcile` выполняет только чтение и может подтвердить исходное, конечное или промежуточное prefix-состояние. + +`mm_ospf_application_restore_plan` принимает `operationId` завершённой legacy OSPF операции этого подключения и владельца. Это новый человеческий план исходных cost. Он сверяет native-конфигурацию с подтверждённым prefix, а затем запечатывает свежие источники и обратные цели: изменение SQL-метрик и порядка рейтинга не превращает восстановление в повтор оптимизации. Неизвестный результат сначала сверяется без записи; восстановление возможно только при подтверждённом native-prefix. Посторонние изменения конфигурации, новый endpoint/epoch, защиты или утрата допуска блокируют его. Универсальный native recovery для этого сервиса не используется. + +SQL/HTTP-проверки, все 27 backend-профилей, SQL logs, настоящий restore/tamper, build/lint прошли. [SQL/HTTP-приёмка](mcp-ospf-legacy-actions-acceptance.json). После неё новые команды отдельно приняты на обоих CHR в конфигурационном сценарии ниже. Прежняя IPv6 GRE-попытка с результатом 2/3 до OSPF остаётся незакрытой пакетной проверкой. + +### CHR: legacy apply и отдельный human restore + +23 октября оба тестовых CHR прошли реальные `mm_ospf_application_optimize_plan` и `mm_ospf_application_restore_plan`: `30/40 → 10/20 → 30/40`, восемь cost PATCH суммарно, сверка без дополнительных записей, 32 подтверждённые MCP-операции с учётом создания и очистки стенда. Все 24 исходных fingerprints двух устройств восстановлены, тестовые объекты удалены. [Неизменяемый протокол](mcp-chr-ospf-legacy-d875e896-acceptance.json), [аудит и source hashes](mcp-chr-ospf-legacy-audit.json). + +Профиль `npm --prefix backend run test:mcp:pg -- --chr-ospf-legacy` работает только с двумя явными CHR из allowlist, отдельным временным PostgreSQL и приватным вводом пароля в terminal. Используются `MMAPP_TEST_CHR_HOSTS`, `MMAPP_TEST_CHR_USER=admin` и при необходимости `MMAPP_TEST_CHR_TUNNEL_PORTS`. До первой записи требуются пустые исходные OSPF templates и отсутствие соседей. Профиль создаёт только уникальные disabled bridge без портов/адресов, отдельную VRF и passive templates; очистка проверяет точное владение и не повторяет неизвестные записи. Выбор одновременно нескольких CHR-профилей запрещён. + +Это приёмка изменения конфигурации. Она не подтверждает Full/BFD, прохождение пакетов, независимый канал или трёхузловый failover и не закрывает прежнюю потерю IPv6 GRE-пакета. Два исторических route hash `c74d0d3c` остаются неподтверждёнными. + +### Готовность GRE-стенда перед пакетной OSPF-приёмкой — в работе + +Начальный bring-up стенда теперь требует два подряд результата строго 3/3 с полной подтверждённой трассой, максимум за четыре read-only наблюдения. Начальный `PING_PARTIAL_LOSS` сохраняется как unknown и сбрасывает серию. Полный отказ, другой unknown, неполная трасса, недостоверные counters и ошибка транспорта останавливают запуск сразу. Проверки после fault/recovery и производственный admission пути не смягчались; повторов конфигурационных команд нет. + +До/после IPv6-пробы сохраняются только поля адресов собственного GRE и безопасная проекция neighbor cache; отсутствующие flags остаются null. Путь и поля cache сверены с [документацией MikroTik IPv6 Neighbor Discovery](https://help.mikrotik.com/docs/spaces/ROS/pages/40992815/IPv6%2BNeighbor%2BDiscovery). Это диагностические наблюдения, не доказательство причины потери пакета. + +Модели, build/lint, SQL/HTTP/restore, профиль auth/MCP и SQL logs прошли. Новый реальный запуск `d33a0265` остановился раньше readiness: GET `/ipv6/address` вернул HTTP 401 при подготовке bridge update. Операция отклонена до записи, все 16 исходных fingerprints восстановлены; OSPF cost и readiness-пробы не выполнялись. Позднее восемь последовательных GET с теми же учётными данными вернули 200. Причина 401 не установлена, настройки сервисов/сессий не менялись. [Прогресс и границы проверки](mcp-overlay-readiness-progress.json), [сохранённый неуспешный запуск](mcp-chr-ospf-forward-d33a0265-auth-failure.json). Историческая потеря IPv6 2/3 также [сохранена отдельно](mcp-chr-ospf-forward-19b53dc9-preflight-failure.json). Пакетная приёмка остаётся открытой. + +### Восстановление GRE из ревизии — локально принято + +`mm_gre_revision_restore_plan({serverId, revisionId})` восстанавливает весь раздел GRE выбранного сервера через общий с REST исполнитель. Выдаётся отдельное разрешение `network.gre.revision.restore`; существующим подключениям оно автоматически не добавляется. Даже autonomous-подключение требует человеческого approval. Review показывает удаляемые/восстанавливаемые GRE, адреса, MTU и enabled; PSK и комментарии не выдаются. Пустой или маскированный PSK старой ревизии сохраняет подтверждённый текущий ключ. Явное удаление PSK выполняется отдельным планом изменения GRE. + +Все затронутые GRE отключаются до изменения адресов; включение выполняется последним. Используемый туннель не удаляется. Перед каждой командой, после durable intent и после read-back сверяются источники, защиты и вся конфигурация. Потеря ответа, игнорирование или изменение зависимостей останавливают пакет без retry и автоматического отката. Предел — 50 native-команд и 120 секунд выполнения. Receipt отдельно показывает подтверждённые команды и запись rollback-ревизии; IPv6 readiness, IPsec SA и forwarding не подтверждаются. + +Для сверки без записи дополнительно выдаются `operations.reconcile` и `mm:network:read`, сохраняются допуск к исходной операции и `mm:network:write`. `mm_operation_reconcile` сравнивает исходный proof или сохранённый проверенный конечный receipt. Без конечного checkpoint желаемое состояние не объявляется достигнутым. После частичного/неизвестного исхода новое восстановление требует нового человеческого плана. + +Проверки: `npm --prefix backend run test:mcp:pg -- --gre-revision --restore` — отдельный временный PostgreSQL и локальный HTTP-стенд; `npm --prefix backend run test:mcp:pg:all -- --restore` — общая SQL/HTTP-приёмка и все 27 профилей. Для настоящего dump/restore нужен `MMAPP_PG_CLIENT_BIN`. Оба прогона, модели, build и ESLint прошли. [Протокол](mcp-gre-revision-restore-acceptance.json). Реальная CHR-приёмка нового адаптера выполнялась, но пока не принята; см. [текущий протокол](mcp-chr-gre-revision-acceptance.json). Актуальный реестр — 328 инструментов, REST 168/62/32 из 262. Полный план остаётся открытым. + + +Проверка конфигурационной привязки расписаний: `npm --prefix backend run test:mcp:telemetry-configuration` и `npm --prefix backend run test:mcp:telemetry-configuration:pg`. Второй профиль создаёт и удаляет собственную локальную PostgreSQL, применяет текущий manifest, выполняет настоящие prepare/approve/apply для Traffic/Uptime/API Ping и проверяет допустимые изменения collector metadata, отказ при изменении настроек и скрытых полей. Профиль запускается отдельно, без `--restore` и без подключения CHR. Миграцию reserved routing отдельно проверяет `npm --prefix backend run test:mcp:routing-schema:pg`; она не включает публичный autoRecovery. + +Адресный повтор упавшего профиля: `npm --prefix backend run test:mcp:pg:all -- --regression-only --profile=test:auth`. Режим принимает ровно один известный профиль из `test:all`, создаёт собственную одноразовую БД и дополнительно проверяет SQL логов. Он явно пропускает основную MCP-интеграцию и terminal restore, не объявляя их повторно принятыми; смешивание с `--restore` или другим отдельным профилем запрещено.